diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 0f2e02380f81b..62fc5399dbee8 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -997,7 +997,8 @@ "--comment-thread-editor-font-weight", "--comment-thread-state-color", "--comment-thread-state-background-color", - "--inline-edit-border-radius" + "--inline-edit-border-radius", + "--chat-subagent-last-item-height" ], "sizes": [ "--vscode-bodyFontSize", diff --git a/eslint.config.js b/eslint.config.js index 37fb7fe63bf5b..b245f9466ac42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,6 +899,7 @@ export default tseslint.config( ], 'verbs': [ 'accept', + 'archive', 'change', 'close', 'collapse', diff --git a/src/vs/base/common/date.ts b/src/vs/base/common/date.ts index 3a169c2bdf555..e5887ef8bd322 100644 --- a/src/vs/base/common/date.ts +++ b/src/vs/base/common/date.ts @@ -24,6 +24,10 @@ const year = day * 365; * is less than 30 seconds. */ export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string { + if (typeof date === 'undefined') { + return localize('date.fromNow.unknown', 'unknown'); + } + if (typeof date !== 'number') { date = date.getTime(); } diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index e31c45120fb03..146bd1d690fac 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -785,6 +785,53 @@ export function lcut(text: string, n: number, prefix = ''): string { return prefix + trimmed.substring(i).trimStart(); } +/** + * Given a string and a max length returns a shortened version keeping the beginning. + * Shortening happens at favorable positions - such as whitespace or punctuation characters. + * Trailing whitespace is always trimmed. + */ +export function rcut(text: string, n: number, suffix = ''): string { + const trimmed = text.trimEnd(); + + if (trimmed.length <= n) { + return trimmed; + } + + const re = /\b/g; + let lastGoodBreak = 0; + let foundBoundaryAfterN = false; + while (re.test(trimmed)) { + if (re.lastIndex > n) { + foundBoundaryAfterN = true; + break; + } + lastGoodBreak = re.lastIndex; + re.lastIndex += 1; + } + + // If no boundary was found after n, return the full trimmed string + // (there's no good place to cut) + if (!foundBoundaryAfterN) { + return trimmed; + } + + // If the only boundary <= n is at position 0 (start of string), + // cutting there gives empty string, so just return the suffix + if (lastGoodBreak === 0) { + return suffix; + } + + const result = trimmed.substring(0, lastGoodBreak).trimEnd(); + + // If trimEnd removed more than half of what we cut (meaning we cut + // mostly through whitespace), return the full string instead + if (result.length < lastGoodBreak / 2) { + return trimmed; + } + + return result + suffix; +} + // Defacto standard: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/; const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/; diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index bb992038f19c1..aeac4aa7b168e 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -196,6 +196,40 @@ suite('Strings', () => { assert.strictEqual(strings.lcut('............a', 10, '…'), '............a'); }); + test('rcut', () => { + assert.strictEqual(strings.rcut('foo bar', 0), ''); + assert.strictEqual(strings.rcut('foo bar', 1), ''); + assert.strictEqual(strings.rcut('foo bar', 3), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 4), 'foo'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 7), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6), 'test'); + + assert.strictEqual(strings.rcut('foo bar', 0, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 1, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 3, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 4, '…'), 'foo…'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 7, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6, '…'), 'test…'); + + assert.strictEqual(strings.rcut('', 10), ''); + assert.strictEqual(strings.rcut('a', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10), 'a............'); + + assert.strictEqual(strings.rcut('', 10, '…'), ''); + assert.strictEqual(strings.rcut('a', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10, '…'), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10, '…'), 'a............'); + }); + test('escape', () => { assert.strictEqual(strings.escape(''), ''); assert.strictEqual(strings.escape('foo'), 'foo'); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 0d56d40bb743a..de412e896a2a4 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -288,6 +288,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA toolId: progress.toolName, chatRequestId: requestId, sessionResource: chatSession?.sessionResource, + subagentInvocationId: progress.subagentInvocationId }); continue; } diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 6a18a39b05ff6..38de78caf4a25 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,7 +382,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } - $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -491,6 +490,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index a0686773ff40b..1976af774dc0f 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -78,7 +78,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre return fn.countTokens(input, token); } - $registerTool(id: string): void { + $registerTool(id: string, hasHandleToolStream: boolean): void { const disposable = this._languageModelToolsService.registerToolImplementation( id, { @@ -93,7 +93,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre } }, prepareToolInvocation: (context, token) => this._proxy.$prepareToolInvocation(id, context, token), - handleToolStream: (context, token) => this._proxy.$handleToolStream(id, context, token), + handleToolStream: hasHandleToolStream ? (context, token) => this._proxy.$handleToolStream(id, context, token) : undefined, }); this._tools.set(id, disposable); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ee8bf713db5d1..2a0d70fdbddb8 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1530,6 +1530,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); + }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c6c821007c47b..3906ecde7f72d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1498,7 +1498,7 @@ export interface MainThreadLanguageModelToolsShape extends IDisposable { $acceptToolProgress(callId: string, progress: IToolProgressStep): void; $invokeTool(dto: Dto, token?: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; - $registerTool(id: string): void; + $registerTool(id: string, hasHandleToolStream: boolean): void; $unregisterTool(name: string): void; } @@ -2330,6 +2330,7 @@ export interface IChatBeginToolInvocationDto { streamData?: { partialInput?: unknown; }; + subagentInvocationId?: string; } export interface IChatUpdateToolInvocationDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 1f47b51ee412d..994bf6dfb232b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -311,7 +311,8 @@ export class ChatAgentResponseStream { toolName, streamData: streamData ? { partialInput: streamData.partialInput - } : undefined + } : undefined, + subagentInvocationId: streamData?.subagentInvocationId }; _report(dto); return this; @@ -559,6 +560,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return await (provider as vscode.InstructionsProvider).provideInstructions(context, token) ?? undefined; case PromptsType.prompt: return await (provider as vscode.PromptFileProvider).providePromptFiles(context, token) ?? undefined; + case PromptsType.skill: + throw new Error('Skills prompt file provider not implemented yet'); } } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index bc7366256c194..c4d34921e4521 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -29,6 +31,177 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +type ChatSessionTiming = vscode.ChatSessionItem['timing']; + +// #region Chat Session Item Controller + +class ChatSessionItemImpl implements vscode.ChatSessionItem { + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #archived?: boolean; + #tooltip?: string | vscode.MarkdownString; + #timing?: ChatSessionTiming; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + readonly resource: vscode.Uri; + + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { + this.resource = resource; + this.#label = label; + this.#onChanged = onChanged; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } + } + + get archived(): boolean | undefined { + return this.#archived; + } + + set archived(value: boolean | undefined) { + if (this.#archived !== value) { + this.#archived = value; + this.#onChanged(); + } + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } + } + + get timing(): ChatSessionTiming | undefined { + return this.#timing; + } + + set timing(value: ChatSessionTiming | undefined) { + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } + } + + get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { + return this.#changes; + } + + set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } + } +} + +class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { + readonly #items = new ResourceMap(); + #onItemsChanged: () => void; + + constructor(onItemsChanged: () => void) { + this.#onItemsChanged = onItemsChanged; + } + + get size(): number { + return this.#items.size; + } + + replace(items: readonly vscode.ChatSessionItem[]): void { + this.#items.clear(); + for (const item of items) { + this.#items.set(item.resource, item); + } + this.#onItemsChanged(); + } + + forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { + for (const [_, item] of this.#items) { + callback.call(thisArg, item, this); + } + } + + add(item: vscode.ChatSessionItem): void { + this.#items.set(item.resource, item); + this.#onItemsChanged(); + } + + delete(resource: vscode.Uri): void { + this.#items.delete(resource); + this.#onItemsChanged(); + } + + get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { + return this.#items.get(resource); + } + + [Symbol.iterator](): Iterator { + return this.#items.entries(); + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -62,13 +235,20 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); + private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -140,6 +320,52 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const controllerHandle = this._nextChatSessionItemControllerHandle++; + const disposables = new DisposableStore(); + + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + }, + dispose: () => { + isDisposed = true; + disposables.dispose(); + }, + }; + + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + })); + + return controller; + } + registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -184,17 +410,25 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { + private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), + archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - startTime: sessionContent.timing?.startTime ?? 0, - endTime: sessionContent.timing?.endTime + created, + lastRequestStarted, + lastRequestEnded, }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : @@ -207,21 +441,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); - if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } + let items: vscode.ChatSessionItem[]; + + const controller = this._chatSessionItemControllers.get(handle); + if (controller) { + // Call the refresh handler to populate items + await controller.controller.refreshHandler(); + if (token.isCancellationRequested) { + return []; + } - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; + items = Array.from(controller.controller.items, x => x[1]); + } else { + + const itemProvider = this._chatSessionItemProviders.get(handle); + if (!itemProvider) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } + + items = await itemProvider.provider.provideChatSessionItems(token) ?? []; + if (token.isCancellationRequested) { + return []; + } } const response: IChatSessionItem[] = []; - for (const sessionContent of sessions) { + for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); + response.push(this.convertChatSessionItem(sessionContent)); } return response; } diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f629148a38973..4d02ef1a57f5a 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -125,7 +125,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape context: options.toolInvocationToken as IToolInvocationContext | undefined, chatRequestId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatRequestId : undefined, chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, - fromSubAgent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.fromSubAgent : undefined, + subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, }, token); @@ -186,7 +186,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionId = dto.context?.sessionId; - options.fromSubAgent = dto.fromSubAgent; + options.subAgentInvocationId = dto.subAgentInvocationId; } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { @@ -315,7 +315,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape registerTool(extension: IExtensionDescription, id: string, tool: vscode.LanguageModelTool): IDisposable { this._registeredTools.set(id, { extension, tool }); - this._proxy.$registerTool(id); + this._proxy.$registerTool(id, typeof tool.handleToolStream === 'function'); return toDisposable(() => { this._registeredTools.delete(id); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index d12a5b9c3756c..02ae97a483182 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2833,7 +2833,7 @@ export namespace ChatToolInvocationPart { : part.presentation === 'hiddenAfterComplete' ? ToolInvocationPresentation.HiddenAfterComplete : undefined, - fromSubAgent: part.fromSubAgent + subAgentInvocationId: part.subAgentInvocationId }; } @@ -2882,7 +2882,7 @@ export namespace ChatToolInvocationPart { if (part.toolSpecificData) { toolInvocation.toolSpecificData = convertFromInternalToolSpecificData(part.toolSpecificData); } - toolInvocation.fromSubAgent = part.fromSubAgent; + toolInvocation.subAgentInvocationId = part.subAgentInvocationId; return toolInvocation; } @@ -3161,7 +3161,7 @@ export namespace ChatAgentRequest { editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), - isSubagent: request.isSubagent, + subAgentInvocationId: request.subAgentInvocationId, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3182,7 +3182,7 @@ export namespace ChatAgentRequest { // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).sessionId; // eslint-disable-next-line local/code-no-any-casts - delete (requestWithAllProps as any).isSubagent; + delete (requestWithAllProps as any).subAgentInvocationId; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 6277175ffcd98..b8d92947c99cd 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3359,7 +3359,7 @@ export class ChatToolInvocationPart { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData2; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 152b390c9a7b5..4cb1a09d8c57c 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -107,6 +107,8 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi if (toolInvocation.toolSpecificData?.kind === 'terminal') { const terminalData = migrateLegacyTerminalToolSpecificData(toolInvocation.toolSpecificData); input = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; + } else if (toolInvocation.toolSpecificData?.kind === 'subagent') { + input = toolInvocation.toolSpecificData.description ?? ''; } else { input = toolInvocation.toolSpecificData?.kind === 'extensions' ? JSON.stringify(toolInvocation.toolSpecificData.extensions) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b579321fec1d4..73776e50163f9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,19 +359,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide a start and end time to track + // Times: it is important to always provide timing information to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let startTime = session.timing.startTime; - let endTime = session.timing.endTime; - if (!startTime || !endTime) { + let created = session.timing.created; + let lastRequestStarted = session.timing.lastRequestStarted; + let lastRequestEnded = session.timing.lastRequestEnded; + if (!created || !lastRequestEnded) { const existing = this._sessions.get(session.resource); - if (!startTime && existing?.timing.startTime) { - startTime = existing.timing.startTime; + if (!created && existing?.timing.created) { + created = existing.timing.created; } - if (!endTime && existing?.timing.endTime) { - endTime = existing.timing.endTime; + if (!lastRequestEnded && existing?.timing.lastRequestEnded) { + lastRequestEnded = existing.timing.lastRequestEnded; + } + + if (!lastRequestStarted && existing?.timing.lastRequestStarted) { + lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -386,7 +391,13 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, + timing: { + created, + lastRequestStarted, + lastRequestEnded, + inProgressTime, + finishedOrFailedTime + }, changes: normalizedChanges, })); } @@ -454,7 +465,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -473,7 +484,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession extends Omit { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -492,7 +503,11 @@ interface ISerializedAgentSession extends Omit ({ + return cached.map((session): IInternalAgentSessionData => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -569,8 +585,10 @@ class AgentSessionsCache { archived: session.archived, timing: { - startTime: session.timing.startTime, - endTime: session.timing.endTime, + // Support loading both new and old cache formats + created: session.timing.created ?? session.timing.startTime ?? 0, + lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, + lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 75cd153a25999..ba320e5e29f2b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -21,13 +21,7 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes session.setRead(true); // mark as read when opened - // Local chat sessions (chat history) should always open in the chat widget - if (isLocalAgentSessionItem(session)) { - await openSessionInChatWidget(accessor, session, openOptions); - return; - } - - // Check if Agent Session Projection is enabled for agent sessions + // Check if Agent Session Projection is enabled const agentSessionProjectionEnabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; if (agentSessionProjectionEnabled) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index cd91ba6fbdb70..ba5bfac455de8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); + const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index f3d3e6e29cdcd..17c8d9f3a5aae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -826,7 +827,9 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); + const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; + const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; + return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts index 0a71dc15ccc8e..8bdedfbde9942 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts @@ -23,8 +23,8 @@ import { IAction } from '../../../../../base/common/actions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; -const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; // Has the keybinding +const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; +const QUICK_CHAT_ACTION_ID = 'workbench.action.quickchat.toggle'; const QUICK_OPEN_ACTION_ID = 'workbench.action.quickOpenWithModes'; /** @@ -118,63 +118,63 @@ export class AgentsControlViewItem extends BaseActionViewItem { pill.classList.add('has-unread'); } pill.setAttribute('role', 'button'); - pill.setAttribute('aria-label', localize('openChat', "Open Chat")); + pill.setAttribute('aria-label', localize('openQuickChat', "Open Quick Chat")); pill.tabIndex = 0; this._container.appendChild(pill); - // Copilot icon (always shown) - const icon = $('span.agents-control-icon'); - reset(icon, renderIcon(Codicon.chatSparkle)); - pill.appendChild(icon); - - // Show workspace name (centered) - const label = $('span.agents-control-label'); - const workspaceName = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); - label.textContent = workspaceName; - pill.appendChild(label); - - // Right side indicator - const rightIndicator = $('span.agents-control-status'); + // Left side indicator (status) + const leftIndicator = $('span.agents-control-status'); if (hasActiveSessions) { // Running indicator when there are active sessions const runningIcon = $('span.agents-control-status-icon'); reset(runningIcon, renderIcon(Codicon.sessionInProgress)); - rightIndicator.appendChild(runningIcon); + leftIndicator.appendChild(runningIcon); const runningCount = $('span.agents-control-status-text'); runningCount.textContent = String(activeSessions.length); - rightIndicator.appendChild(runningCount); + leftIndicator.appendChild(runningCount); } else if (hasUnreadSessions) { // Unread indicator when there are unread sessions const unreadIcon = $('span.agents-control-status-icon'); reset(unreadIcon, renderIcon(Codicon.circleFilled)); - rightIndicator.appendChild(unreadIcon); + leftIndicator.appendChild(unreadIcon); const unreadCount = $('span.agents-control-status-text'); unreadCount.textContent = String(unreadSessions.length); - rightIndicator.appendChild(unreadCount); + leftIndicator.appendChild(unreadCount); } else { // Keyboard shortcut when idle (show open chat keybinding) const kb = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); if (kb) { const kbLabel = $('span.agents-control-keybinding'); kbLabel.textContent = kb; - rightIndicator.appendChild(kbLabel); + leftIndicator.appendChild(kbLabel); } } - pill.appendChild(rightIndicator); + pill.appendChild(leftIndicator); + + // Show workspace name (centered) + const label = $('span.agents-control-label'); + const workspaceName = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); + label.textContent = workspaceName; + pill.appendChild(label); + + // Send icon (right side) + const sendIcon = $('span.agents-control-send'); + reset(sendIcon, renderIcon(Codicon.send)); + pill.appendChild(sendIcon); // Setup hover with keyboard shortcut const hoverDelegate = getDefaultHoverDelegate('mouse'); - const kbForTooltip = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + const kbForTooltip = this.keybindingService.lookupKeybinding(QUICK_CHAT_ACTION_ID)?.getLabel(); const tooltip = kbForTooltip - ? localize('askTooltip', "Open Chat ({0})", kbForTooltip) - : localize('askTooltip2', "Open Chat"); + ? localize('askTooltip', "Open Quick Chat ({0})", kbForTooltip) + : localize('askTooltip2', "Open Quick Chat"); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, tooltip)); - // Click handler - open chat + // Click handler - open quick chat disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + this.commandService.executeCommand(QUICK_CHAT_ACTION_ID); })); // Keyboard handler @@ -182,7 +182,7 @@ export class AgentsControlViewItem extends BaseActionViewItem { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + this.commandService.executeCommand(QUICK_CHAT_ACTION_ID); } })); @@ -198,18 +198,13 @@ export class AgentsControlViewItem extends BaseActionViewItem { const pill = $('div.agents-control-pill.session-mode'); this._container.appendChild(pill); - // Copilot icon - const iconContainer = $('span.agents-control-icon'); - reset(iconContainer, renderIcon(Codicon.chatSparkle)); - pill.appendChild(iconContainer); - - // Session title + // Session title (left/center) const titleLabel = $('span.agents-control-title'); const session = this.focusViewService.activeSession; titleLabel.textContent = session?.label ?? localize('agentSessionProjection', "Agent Session Projection"); pill.appendChild(titleLabel); - // Close button + // Close button (right side) const closeButton = $('span.agents-control-close'); closeButton.classList.add('codicon', 'codicon-close'); closeButton.setAttribute('role', 'button'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts index cfcd09839dd86..3225c5f6ebd6f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts @@ -23,6 +23,7 @@ import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; +import { IChatEditingService, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; //#region Configuration @@ -31,11 +32,12 @@ import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; * Only sessions from these providers will trigger focus view. * * Configuration: - * - AgentSessionProviders.Local: Local chat sessions (disabled) + * - AgentSessionProviders.Local: Local chat sessions (enabled) * - AgentSessionProviders.Background: Background CLI agents (enabled) * - AgentSessionProviders.Cloud: Cloud agents (enabled) */ const AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS: Set = new Set([ + AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Cloud, ]); @@ -118,6 +120,7 @@ export class FocusViewService extends Disposable implements IFocusViewService { @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ICommandService private readonly commandService: ICommandService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, ) { super(); @@ -203,38 +206,58 @@ export class FocusViewService extends Disposable implements IFocusViewService { return; } - if (!this._isActive) { - // First time entering focus view - save the current working set as our "non-focus-view" backup - this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); - } else if (this._activeSession) { - // Already in focus view, switching sessions - save the current session's working set - const previousSessionKey = this._activeSession.resource.toString(); - const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); - this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + // For local sessions, check if there are pending edits to show + // If there's nothing to focus, just open the chat without entering focus view mode + let hasUndecidedChanges = true; + if (session.providerType === AgentSessionProviders.Local) { + const editingSession = this.chatEditingService.getEditingSession(session.resource); + hasUndecidedChanges = editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified) ?? false; + if (!hasUndecidedChanges) { + this.logService.trace('[FocusView] Local session has no undecided changes, opening chat without focus view'); + } } - // Always open session files to ensure they're displayed - await this._openSessionFiles(session); - - // Set active state - const wasActive = this._isActive; - this._isActive = true; - this._activeSession = session; - this._inFocusViewModeContextKey.set(true); - this.layoutService.mainContainer.classList.add('focus-view-active'); - if (!wasActive) { - this._onDidChangeFocusViewMode.fire(true); + // Only enter focus view mode if there are changes to show + if (hasUndecidedChanges) { + if (!this._isActive) { + // First time entering focus view - save the current working set as our "non-focus-view" backup + this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); + } else if (this._activeSession) { + // Already in focus view, switching sessions - save the current session's working set + const previousSessionKey = this._activeSession.resource.toString(); + const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); + this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + } + + // Always open session files to ensure they're displayed + await this._openSessionFiles(session); + + // Set active state + const wasActive = this._isActive; + this._isActive = true; + this._activeSession = session; + this._inFocusViewModeContextKey.set(true); + this.layoutService.mainContainer.classList.add('focus-view-active'); + if (!wasActive) { + this._onDidChangeFocusViewMode.fire(true); + } + // Always fire session change event (for title updates when switching sessions) + this._onDidChangeActiveSession.fire(session); } - // Always fire session change event (for title updates when switching sessions) - this._onDidChangeActiveSession.fire(session); - // Open the session in the chat panel + // Open the session in the chat panel (always, even without changes) session.setRead(true); await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget, { title: { preferred: session.label }, revealIfOpened: true }); + + // For local sessions with changes, also pop open the edit session's changes view + // Must be after openSession so the editing session context is available + if (session.providerType === AgentSessionProviders.Local && hasUndecidedChanges) { + await this.commandService.executeCommand('chatEditing.viewChanges'); + } } async exitFocusView(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 499cc8015c72e..8b2b3947efb44 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -12,7 +12,7 @@ import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatModel } from '../../common/model/chatModel.js'; -import { IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; +import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -115,7 +115,7 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess description, status: model ? this.modelToStatus(model) : this.chatResponseStateToStatus(chat.lastResponseState), iconPath: Codicon.chatSparkle, - timing: chat.timing, + timing: convertLegacyChatSessionTiming(chat.timing), changes: chat.stats ? { insertions: chat.stats.added, deletions: chat.stats.removed, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css index bea8ba912b9e0..1e8996943c26f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* ======================================== -Focus View Mode - Blue glow border around entire workbench +Focus View Mode - Themed glow border around entire workbench ======================================== */ .monaco-workbench.focus-view-active::after { @@ -13,7 +13,7 @@ Focus View Mode - Blue glow border around entire workbench inset: 0; pointer-events: none; z-index: 10000; - box-shadow: inset 0 0 0 3px rgba(0, 120, 212, 0.8), inset 0 0 30px rgba(0, 120, 212, 0.4); + box-shadow: inset 0 0 0 3px var(--vscode-progressBar-background), inset 0 0 30px color-mix(in srgb, var(--vscode-progressBar-background) 35%, transparent); transition: box-shadow 0.2s ease-in-out; } @@ -85,18 +85,17 @@ Agents Control - Titlebar control /* Active state - has running sessions */ .agents-control-pill.chat-input-mode.has-active { - background-color: rgba(0, 120, 212, 0.15); - border: 1px solid rgba(0, 120, 212, 0.5); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); } .agents-control-pill.chat-input-mode.has-active:hover { - background-color: rgba(0, 120, 212, 0.25); - border-color: rgba(0, 120, 212, 0.7); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + border-color: color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent); } -.agents-control-pill.chat-input-mode.has-active .agents-control-icon, .agents-control-pill.chat-input-mode.has-active .agents-control-label { - color: var(--vscode-textLink-foreground); + color: var(--vscode-progressBar-background); opacity: 1; } @@ -107,27 +106,14 @@ Agents Control - Titlebar control /* Session mode (viewing a session) */ .agents-control-pill.session-mode { - background-color: rgba(0, 120, 212, 0.15); - border: 1px solid rgba(0, 120, 212, 0.5); + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); padding: 0 12px; } .agents-control-pill.session-mode:hover { - background-color: rgba(0, 120, 212, 0.25); - border-color: rgba(0, 120, 212, 0.7); -} - -/* Icon */ -.agents-control-icon { - display: flex; - align-items: center; - color: var(--vscode-foreground); - opacity: 0.7; -} - -.agents-control-pill.session-mode .agents-control-icon { - color: var(--vscode-textLink-foreground); - opacity: 1; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + border-color: color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent); } /* Label (workspace name, centered) */ @@ -141,10 +127,8 @@ Agents Control - Titlebar control text-overflow: ellipsis; } -/* Right side status indicator */ +/* Left side status indicator */ .agents-control-status { - position: absolute; - right: 8px; display: flex; align-items: center; gap: 4px; @@ -152,7 +136,7 @@ Agents Control - Titlebar control } .agents-control-pill.has-active .agents-control-status { - color: var(--vscode-textLink-foreground); + color: var(--vscode-progressBar-background); } .agents-control-status-icon { @@ -170,6 +154,19 @@ Agents Control - Titlebar control opacity: 0.7; } +/* Send icon (right side) */ +.agents-control-send { + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +.agents-control-pill.has-active .agents-control-send { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + /* Session title */ .agents-control-title { flex: 1; @@ -180,7 +177,7 @@ Agents Control - Titlebar control white-space: nowrap; } -/* Close button */ +/* Close button (right side in session mode) */ .agents-control-close { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 529e2d3890da9..77992fd260918 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -54,7 +54,7 @@ import { ILanguageModelToolsConfirmationService } from '../common/tools/language import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; @@ -136,6 +136,7 @@ import { ChatWidgetService } from './widget/chatWidgetService.js'; import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; +import { VALID_SKILL_PATH_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -718,11 +719,37 @@ configurationRegistry.registerConfiguration({ [PromptsConfig.USE_AGENT_SKILLS]: { type: 'boolean', title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), - markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from `.github/skills`, `~/.copilot/skills`, `.claude/skills`, and `~/.claude/skills`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), - default: false, + markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from the folders configured in `#chat.agentSkillsLocations#`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), + default: true, restricted: true, disallowConfigurationDefault: true, - tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.SKILLS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), + markdownDescription: nls.localize('chat.agentSkillsLocations.description', "Specify where agent skills are located. Each path should contain skill subfolders with SKILL.md files (e.g., my-skills/skillA/SKILL.md → add my-skills).\n\n**Supported path types:**\n- Workspace paths: `my-skills`, `./my-skills`, `../shared-skills`\n- User home paths: `~/.copilot/skills`, `~/.claude/skills`",), + default: { + ...DEFAULT_SKILL_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_SKILL_PATH_PATTERN, + patternErrorMessage: nls.localize('chat.agentSkillsLocations.invalidPath', "Skill location paths must either be relative paths or start with '~' for user home directory."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + }, + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + 'my-skills': true, + '../shared-skills': true, + '~/.custom/skills': true, + }, + ], }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index 76f0b41e3caf6..f957605808775 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -10,10 +10,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; @@ -33,17 +30,16 @@ export interface IChatSessionPickerDelegate { * These options are provided by the relevant ChatSession Provider */ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewItem { - currentOption: IChatSessionProviderOptionItem | undefined; + protected currentOption: IChatSessionProviderOptionItem | undefined; + protected container: HTMLElement | undefined; + constructor( action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, - private readonly delegate: IChatSessionPickerDelegate, + protected readonly delegate: IChatSessionPickerDelegate, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, - @ICommandService commandService: ICommandService, - @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @IKeybindingService keybindingService: IKeybindingService, - @ITelemetryService telemetryService: ITelemetryService, ) { const { group, item } = initialState; const actionWithLabel: IAction = { @@ -55,44 +51,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI const sessionPickerActionWidgetOptions: Omit = { actionProvider: { - getActions: () => { - // if locked, show the current option only - const currentOption = this.delegate.getCurrentOption(); - if (currentOption?.locked) { - return [{ - id: currentOption.id, - enabled: false, - icon: currentOption.icon, - checked: true, - class: undefined, - description: undefined, - tooltip: currentOption.description ?? currentOption.name, - label: currentOption.name, - run: () => { } - } satisfies IActionWidgetDropdownAction]; - } else { - const group = this.delegate.getOptionGroup(); - if (!group) { - return []; - } - return group.items.map(optionItem => { - const isCurrent = optionItem.id === this.delegate.getCurrentOption()?.id; - return { - id: optionItem.id, - enabled: true, - icon: optionItem.icon, - checked: isCurrent, - class: undefined, - description: undefined, - tooltip: optionItem.description ?? optionItem.name, - label: optionItem.name, - run: () => { - this.delegate.setOption(optionItem); - } - } satisfies IActionWidgetDropdownAction; - }); - } - } + getActions: () => this.getDropdownActions() }, actionBarActionProvider: undefined, }; @@ -105,24 +64,103 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI if (this.element) { this.renderLabel(this.element); } + this.updateEnabled(); })); } + + /** + * Returns the actions to show in the dropdown. Can be overridden by subclasses. + */ + protected getDropdownActions(): IActionWidgetDropdownAction[] { + // if locked, show the current option only + const currentOption = this.delegate.getCurrentOption(); + if (currentOption?.locked) { + return [this.createLockedOptionAction(currentOption)]; + } + + const group = this.delegate.getOptionGroup(); + if (!group) { + return []; + } + + return group.items.map(optionItem => { + const isCurrent = optionItem.id === currentOption?.id; + return { + id: optionItem.id, + enabled: !optionItem.locked, + icon: optionItem.icon, + checked: isCurrent, + class: undefined, + description: undefined, + tooltip: optionItem.description ?? optionItem.name, + label: optionItem.name, + run: () => { + this.delegate.setOption(optionItem); + } + } satisfies IActionWidgetDropdownAction; + }); + } + + /** + * Creates a disabled action for a locked option. + */ + protected createLockedOptionAction(option: IChatSessionProviderOptionItem): IActionWidgetDropdownAction { + return { + id: option.id, + enabled: false, + icon: option.icon, + checked: true, + class: undefined, + description: undefined, + tooltip: option.description ?? option.name, + label: option.name, + run: () => { } + }; + } + protected override renderLabel(element: HTMLElement): IDisposable | null { const domChildren = []; element.classList.add('chat-session-option-picker'); + if (this.currentOption?.icon) { domChildren.push(renderIcon(this.currentOption.icon)); } domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? localize('chat.sessionPicker.label', "Pick Option"))); + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); return null; } override render(container: HTMLElement): void { + this.container = container; super.render(container); - container.classList.add('chat-sessionPicker-item'); + container.classList.add(this.getContainerClass()); + + // Set initial locked state on container + if (this.currentOption?.locked) { + container.classList.add('locked'); + } + } + + /** + * Returns the CSS class to add to the container. Can be overridden by subclasses. + */ + protected getContainerClass(): string { + return 'chat-sessionPicker-item'; } + protected override updateEnabled(): void { + const originalEnabled = this.action.enabled; + if (this.currentOption?.locked) { + this.action.enabled = false; + } + super.updateEnabled(); + this.action.enabled = originalEnabled; + if (this.container) { + this.container.classList.toggle('locked', !!this.currentOption?.locked); + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css index 57dc5afbaeb5b..05791a7f5306b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css @@ -37,3 +37,17 @@ margin-left: 2px; } } + +/* Locked state styling - matches disabled action item styling */ +.monaco-action-bar .action-item.locked .chat-session-option-picker { + color: var(--vscode-disabledForeground); + cursor: default; +} + +.monaco-action-bar .action-item.locked .chat-session-option-picker .codicon { + color: var(--vscode-disabledForeground); +} + +.monaco-action-bar .action-item.locked { + pointer-events: none; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts index 8a7d7742419b3..16ef8fb073692 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts @@ -9,18 +9,16 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation 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, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.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 { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.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 { Codicon } from '../../../../../base/common/codicons.js'; -import { IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; interface ISearchableOptionQuickPickItem extends IQuickPickItem { @@ -36,102 +34,69 @@ function isSearchableOptionQuickPickItem(item: IQuickPickItem | undefined): item * 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 ActionWidgetDropdownActionViewItem { - private currentOption: IChatSessionProviderOptionItem | undefined; +export class SearchableOptionPickerActionItem extends ChatSessionPickerActionItem { private static readonly SEE_MORE_ID = '__see_more__'; constructor( action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, - private readonly delegate: IChatSessionPickerDelegate, + delegate: IChatSessionPickerDelegate, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService, @IQuickInputService private readonly quickInputService: IQuickInputService, @ILogService private readonly logService: ILogService, ) { - const { group, item } = initialState; - const actionWithLabel: IAction = { - ...action, - label: item?.name || group.name, - tooltip: item?.description ?? group.description ?? group.name, - run: () => { } - }; - - const searchablePickerOptions: Omit = { - actionProvider: { - getActions: () => { - // If locked, show the current option only - const currentOption = this.delegate.getCurrentOption(); - if (currentOption?.locked) { - return [{ - id: currentOption.id, - enabled: false, - icon: currentOption.icon, - checked: true, - class: undefined, - description: undefined, - tooltip: currentOption.description ?? currentOption.name, - label: currentOption.name, - run: () => { } - } satisfies IActionWidgetDropdownAction]; - } - - const actions: IActionWidgetDropdownAction[] = []; - const optionGroup = this.delegate.getOptionGroup(); - if (!optionGroup) { - return []; - } + super(action, initialState, delegate, actionWidgetService, contextKeyService, keybindingService); + } - // Build actions from items - optionGroup.items.map(optionItem => { - const isCurrent = optionItem.id === currentOption?.id; - actions.push({ - id: optionItem.id, - enabled: !optionItem.locked, - icon: optionItem.icon, - checked: isCurrent, - class: undefined, - description: undefined, - tooltip: optionItem.description ?? optionItem.name, - label: optionItem.name, - run: () => { - this.delegate.setOption(optionItem); - } - }); - }); + protected override getDropdownActions(): IActionWidgetDropdownAction[] { + // If locked, show the current option only + const currentOption = this.delegate.getCurrentOption(); + if (currentOption?.locked) { + return [this.createLockedOptionAction(currentOption)]; + } - // 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); - } + const optionGroup = this.delegate.getOptionGroup(); + if (!optionGroup) { + return []; + } - return actions; + // 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: undefined, + tooltip: optionItem.description ?? optionItem.name, + label: optionItem.name, + run: () => { + this.delegate.setOption(optionItem); } - }, - actionBarActionProvider: undefined, - }; + }; + }); - super(actionWithLabel, searchablePickerOptions, actionWidgetService, keybindingService, contextKeyService); - this.currentOption = item; + // 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); + } - this._register(this.delegate.onDidChangeOption(newOption => { - this.currentOption = newOption; - if (this.element) { - this.renderLabel(this.element); - } - })); + return actions; } protected override renderLabel(element: HTMLElement): IDisposable | null { @@ -139,6 +104,7 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction const optionGroup = this.delegate.getOptionGroup(); element.classList.add('chat-session-option-picker'); + if (optionGroup?.icon) { domChildren.push(renderIcon(optionGroup.icon)); } @@ -147,22 +113,15 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction const label = this.currentOption?.name ?? optionGroup?.name ?? localize('selectOption', "Select..."); domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); - // Chevron domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); - // Locked indicator - if (this.currentOption?.locked) { - domChildren.push(renderIcon(Codicon.lock)); - } - dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); return null; } - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('chat-searchable-option-picker-item'); + protected override getContainerClass(): string { + return 'chat-searchable-option-picker-item'; } /** diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index d1b74bbdb7ece..960490245e5cf 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -34,7 +34,7 @@ export async function askForPromptSourceFolder( const workspaceService = accessor.get(IWorkspaceContextService); // get prompts source folders based on the prompt type - const folders = promptsService.getSourceFolders(type); + const folders = await promptsService.getSourceFolders(type); // if no source folders found, show 'learn more' dialog // note! this is a temporary solution and must be replaced with a dialog to select diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index f99947bb59539..71ce3a1a959a8 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -15,7 +15,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; -import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID } from '../newPromptFileActions.js'; import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js'; import { askForPromptFileName } from './askForPromptName.js'; @@ -90,6 +90,12 @@ function newHelpButton(type: PromptsType): IQuickInputButton & { helpURI: URI } helpURI: URI.parse(AGENT_DOCUMENTATION_URL), iconClass }; + case PromptsType.skill: + return { + tooltip: localize('help.skill', "Show help on skill files"), + helpURI: URI.parse(SKILL_DOCUMENTATION_URL), + iconClass + }; } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index b5c42764ddfee..e56ca5d25cdf9 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -390,7 +390,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters); } else { // Create a new tool invocation (no streaming phase) - toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.fromSubAgent, dto.parameters); + toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters); this._chatService.appendProgress(request, toolInvocation); } @@ -585,12 +585,18 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } + // Don't create a streaming invocation for tools that don't implement handleToolStream. + // These tools will have their invocation created directly in invokeToolInternal. + if (!toolEntry.impl?.handleToolStream) { + return undefined; + } + // Create the invocation in streaming state const invocation = ChatToolInvocation.createStreaming({ toolCallId: options.toolCallId, toolId: options.toolId, toolData: toolEntry.data, - fromSubAgent: options.fromSubAgent, + subagentInvocationId: options.subagentInvocationId, chatRequestId: options.chatRequestId, }); @@ -602,9 +608,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const model = this._chatService.getSession(options.sessionResource); if (model) { // Find the request by chatRequestId if available, otherwise use the last request - const request = options.chatRequestId + const request = (options.chatRequestId ? model.getRequests().find(r => r.id === options.chatRequestId) - : model.getRequests().at(-1); + : undefined) ?? model.getRequests().at(-1); if (request) { this._chatService.appendProgress(request, invocation); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts new file mode 100644 index 0000000000000..1d4d8b9095dfe --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; + +/** + * A collapsible content part that displays markdown content. + * The title is shown in the collapsed state, and the full content is shown when expanded. + */ +export class ChatCollapsibleMarkdownContentPart extends ChatCollapsibleContentPart { + + private contentElement: HTMLElement | undefined; + + constructor( + title: string, + private readonly markdownContent: string, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IHoverService hoverService: IHoverService, + ) { + super(title, context, undefined, hoverService); + this.icon = Codicon.check; + } + + protected override initContent(): HTMLElement { + const wrapper = $('.chat-collapsible-markdown-content.chat-used-context-list'); + + if (this.markdownContent) { + this.contentElement = $('.chat-collapsible-markdown-body'); + const rendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(this.markdownContent), { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + })); + this.contentElement.appendChild(rendered.element); + wrapper.appendChild(this.contentElement); + } + + return wrapper; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // This part is embedded in the subagent part, not rendered directly + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index 394a981cbecce..a163fb84ca511 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -53,20 +53,51 @@ type ContentRefData = readonly range?: IRange; }; +type InlineAnchorWidgetMetadata = { + vscodeLinkType: string; + linkText?: string; +}; + export function renderFileWidgets(element: HTMLElement, instantiationService: IInstantiationService, chatMarkdownAnchorService: IChatMarkdownAnchorService, disposables: DisposableStore) { // eslint-disable-next-line no-restricted-syntax const links = element.querySelectorAll('a'); links.forEach(a => { // Empty link text -> render file widget - if (!a.textContent?.trim()) { - const href = a.getAttribute('data-href'); - const uri = href ? URI.parse(href) : undefined; - if (uri?.scheme) { - const widget = instantiationService.createInstance(InlineAnchorWidget, a, { kind: 'inlineReference', inlineReference: uri }); - disposables.add(chatMarkdownAnchorService.register(widget)); - disposables.add(widget); + // Also support metadata format: [linkText](file:///...uri?vscodeLinkType=...) + const linkText = a.textContent?.trim(); + let shouldRenderWidget = false; + let metadata: InlineAnchorWidgetMetadata | undefined; + + const href = a.getAttribute('data-href'); + let uri: URI | undefined; + if (href) { + try { + uri = URI.parse(href); + } catch { + // Invalid URI, skip rendering widget + } + } + + if (!linkText) { + shouldRenderWidget = true; + } else if (uri) { + // Check for vscodeLinkType in query parameters + const searchParams = new URLSearchParams(uri.query); + const vscodeLinkType = searchParams.get('vscodeLinkType'); + if (vscodeLinkType) { + metadata = { + vscodeLinkType, + linkText + }; + shouldRenderWidget = true; } } + + if (shouldRenderWidget && uri?.scheme) { + const widget = instantiationService.createInstance(InlineAnchorWidget, a, { kind: 'inlineReference', inlineReference: uri }, metadata); + disposables.add(chatMarkdownAnchorService.register(widget)); + disposables.add(widget); + } }); } @@ -81,6 +112,7 @@ export class InlineAnchorWidget extends Disposable { constructor( private readonly element: HTMLAnchorElement | HTMLElement, public readonly inlineReference: IChatContentInlineReference, + private readonly metadata: InlineAnchorWidgetMetadata | undefined, @IContextKeyService originalContextKeyService: IContextKeyService, @IContextMenuService contextMenuService: IContextMenuService, @IFileService fileService: IFileService, @@ -126,7 +158,8 @@ export class InlineAnchorWidget extends Disposable { } else { location = this.data; - const filePathLabel = labelService.getUriBasenameLabel(location.uri); + const filePathLabel = this.metadata?.linkText ?? labelService.getUriBasenameLabel(location.uri); + if (location.range && this.data.kind !== 'symbol') { const suffix = location.range.startLineNumber === location.range.endLineNumber ? `:${location.range.startLineNumber}` diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 6e26df575f1f2..8e571e80b466f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -278,7 +278,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return ref.object.element; } else { const requestId = isRequestVM(element) ? element.id : element.requestId; - const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri, this.markdown.fromSubagent); + const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri); if (isResponseVM(codeBlockInfo.element)) { // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously this.codeBlockModelCollection.update(codeBlockInfo.element.sessionResource, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { @@ -382,10 +382,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } - private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined, fromSubagent?: boolean): IDisposableReference { + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { - codeBlock.render(codemapperUri, fromSubagent); + codeBlock.render(codemapperUri); } return { object: codeBlock, @@ -551,9 +551,7 @@ export class CollapsedCodeBlock extends Disposable { * @param uri URI of the file on-disk being changed * @param isStreaming Whether the edit has completed (at the time of this being rendered) */ - render(uri: URI, fromSubagent?: boolean): void { - this.pillElement.classList.toggle('from-sub-agent', !!fromSubagent); - + render(uri: URI): void { this.progressStore.clear(); this._uri = uri; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index fd0dafe3b6056..cd6169b793e09 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -245,7 +245,7 @@ export class ChatMarkdownDecorationsRenderer { return; } - const inlineAnchor = store.add(this.instantiationService.createInstance(InlineAnchorWidget, a, data)); + const inlineAnchor = store.add(this.instantiationService.createInstance(InlineAnchorWidget, a, data, undefined)); store.add(this.chatMarkdownAnchorService.register(inlineAnchor)); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts new file mode 100644 index 0000000000000..ed335fca9d70f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { rcut } from '../../../../../../base/common/strings.js'; +import { localize } from '../../../../../../nls.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IRunSubagentToolInputParams, RunSubagentTool } from '../../../common/tools/builtinTools/runSubagentTool.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; +import './media/chatSubagentContent.css'; + +const MAX_TITLE_LENGTH = 100; + +/** + * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not + * trying to refactor to share code. Both could probably be simplified when stable. + */ +export class ChatSubagentContentPart extends ChatCollapsibleContentPart implements IChatContentPart { + private wrapper!: HTMLElement; + private isActive: boolean = true; + private hasToolItems: boolean = false; + private readonly isInitiallyComplete: boolean; + private promptContainer: HTMLElement | undefined; + private resultContainer: HTMLElement | undefined; + private lastItemWrapper: HTMLElement | undefined; + private readonly layoutScheduler: RunOnceScheduler; + private description: string; + private agentName: string | undefined; + private prompt: string | undefined; + + /** + * Extracts subagent info (description, agentName, prompt) from a tool invocation. + */ + private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined } { + const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); + + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + // Check toolSpecificData first (works for both live and serialized) + if (toolInvocation.toolSpecificData?.kind === 'subagent') { + return { + description: toolInvocation.toolSpecificData.description ?? defaultDescription, + agentName: toolInvocation.toolSpecificData.agentName, + prompt: toolInvocation.toolSpecificData.prompt, + }; + } + + // Fallback to parameters for live invocations + if (toolInvocation.kind === 'toolInvocation') { + const state = toolInvocation.state.get(); + const params = state.type !== IChatToolInvocation.StateKind.Streaming ? + state.parameters as IRunSubagentToolInputParams | undefined + : undefined; + return { + description: params?.description ?? defaultDescription, + agentName: params?.agentName, + prompt: params?.prompt, + }; + } + + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + constructor( + public readonly subAgentInvocationId: string, + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + private readonly context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, + ) { + // Extract description, agentName, and prompt from toolInvocation + const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + + // Build title: "AgentName: description" or "Subagent: description" + const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); + const initialTitle = `${prefix}: ${description}`; + super(initialTitle, context, undefined, hoverService); + + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.isInitiallyComplete = this.element.isComplete; + + const node = this.domNode; + node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); + node.tabIndex = 0; + + // Hide initially until there are tool calls + node.style.display = 'none'; + + if (this._collapseButton && !this.element.isComplete) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } + + this._register(autorun(r => { + this.expanded.read(r); + if (this._collapseButton && this.wrapper) { + if (this.wrapper.classList.contains('chat-thinking-streaming') && !this.element.isComplete && this.isActive) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } else { + this._collapseButton.icon = Codicon.check; + } + } + })); + + // Start collapsed - fixed scrolling mode shows limited height when collapsed + this.setExpanded(false); + + // Scheduler for coalescing layout operations + this.layoutScheduler = this._register(new RunOnceScheduler(() => this.performLayout(), 0)); + + // Render the prompt section at the start if available (must be after wrapper is initialized) + this.renderPromptSection(); + + // Watch for completion and render result + this.watchToolCompletion(toolInvocation); + } + + protected override initContent(): HTMLElement { + const baseClasses = '.chat-used-context-list.chat-thinking-collapsible'; + const classes = this.isInitiallyComplete + ? baseClasses + : `${baseClasses}.chat-thinking-streaming`; + this.wrapper = $(classes); + return this.wrapper; + } + + /** + * Renders the prompt as a collapsible section at the start of the content. + */ + private renderPromptSection(): void { + if (!this.prompt || this.promptContainer) { + return; + } + + // Split into first line and rest + const lines = this.prompt.split('\n'); + const rawFirstLine = lines[0] || localize('chat.subagent.prompt', 'Prompt'); + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : (restOfLines || this.prompt); + + // Create collapsible prompt part with comment icon + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + collapsiblePart.icon = Codicon.comment; + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.promptContainer = collapsiblePart.domNode; + // Insert at the beginning of the wrapper + if (this.wrapper.firstChild) { + this.wrapper.insertBefore(this.promptContainer, this.wrapper.firstChild); + } else { + dom.append(this.wrapper, this.promptContainer); + } + } + + public getIsActive(): boolean { + return this.isActive; + } + + public markAsInactive(): void { + this.isActive = false; + this.wrapper.classList.remove('chat-thinking-streaming'); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + this.finalizeTitle(); + // Collapse when done + this.setExpanded(false); + this._onDidChangeHeight.fire(); + } + + public finalizeTitle(): void { + this.updateTitle(); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + } + + private updateTitle(): void { + if (this._collapseButton) { + const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); + const finalLabel = `${prefix}: ${this.description}`; + this._collapseButton.label = finalLabel; + } + } + + /** + * Watches the tool invocation for completion and renders the result. + * Handles both live and serialized invocations. + */ + private watchToolCompletion(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return; + } + + if (toolInvocation.kind === 'toolInvocation') { + // Watch for completion and render the result + let wasStreaming = toolInvocation.state.get().type === IChatToolInvocation.StateKind.Streaming; + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + wasStreaming = false; + // Extract text from result + const textParts = (state.contentForModel || []) + .filter((part): part is { kind: 'text'; value: string } => part.kind === 'text') + .map(part => part.value); + + if (textParts.length > 0) { + this.renderResultText(textParts.join('\n')); + } + + // Mark as inactive when the tool completes + this.markAsInactive(); + } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { + wasStreaming = false; + // Update things that change when tool is done streaming + const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.renderPromptSection(); + this.updateTitle(); + } + })); + } else if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.result) { + // Render the persisted result for serialized invocations + this.renderResultText(toolInvocation.toolSpecificData.result); + // Already complete, mark as inactive + this.markAsInactive(); + } + } + + public renderResultText(resultText: string): void { + if (this.resultContainer || !resultText) { + return; // Already rendered or no content + } + + // Split into first line and rest + const lines = resultText.split('\n'); + const rawFirstLine = lines[0] || ''; + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : restOfLines; + + // Create collapsible result part + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.resultContainer = collapsiblePart.domNode; + dom.append(this.wrapper, this.resultContainer); + + // Show the container if it was hidden + if (this.domNode.style.display === 'none') { + this.domNode.style.display = ''; + } + + this._onDidChangeHeight.fire(); + } + + public appendItem(content: HTMLElement, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (!content.hasChildNodes() || content.textContent?.trim() === '') { + return; + } + + // Show the container when first tool item is added + if (!this.hasToolItems) { + this.hasToolItems = true; + this.domNode.style.display = ''; + } + + // Wrap with icon like thinking parts do, but skip icon for tools needing confirmation + const itemWrapper = $('.chat-thinking-tool-wrapper'); + let needsConfirmation = false; + if (toolInvocation.kind === 'toolInvocation' && toolInvocation.state) { + const state = toolInvocation.state.get(); + needsConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; + } + + if (!needsConfirmation) { + const icon = getToolInvocationIcon(toolInvocation.toolId); + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + } + itemWrapper.appendChild(content); + + // Insert before result container if it exists, otherwise append + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + this.lastItemWrapper = itemWrapper; + + // Watch for tool completion to update height when label changes + if (toolInvocation.kind === 'toolInvocation') { + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + this._onDidChangeHeight.fire(); + } + })); + } + + // Schedule layout to measure last item and scroll + this.layoutScheduler.schedule(); + } + + private performLayout(): void { + // Measure last item height once after layout, set CSS variable for collapsed max-height + if (this.lastItemWrapper) { + const itemHeight = this.lastItemWrapper.offsetHeight; + const height = itemHeight + 4; + if (height > 0) { + this.wrapper.style.setProperty('--chat-subagent-last-item-height', `${height}px`); + } + } + + // Auto-scroll to bottom only when actively streaming (not for completed responses) + if (this.isActive && !this.isInitiallyComplete) { + const scrollHeight = this.wrapper.scrollHeight; + this.wrapper.scrollTop = scrollHeight; + } + + this._onDidChangeHeight.fire(); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || other.toolId === RunSubagentTool.Id)) { + // For runSubagent tool, use toolCallId as the effective ID + const otherEffectiveId = other.toolId === RunSubagentTool.Id ? other.toolCallId : other.subAgentInvocationId; + // If both have IDs, they must match + if (this.subAgentInvocationId && otherEffectiveId) { + return this.subAgentInvocationId === otherEffectiveId; + } + // Fallback for tools without IDs - group if this part has no ID and tool has no ID + return !this.subAgentInvocationId && !otherEffectiveId; + } + return false; + } +} 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 d26cbe3869fb1..14576106a78c5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -35,7 +35,7 @@ function extractTextFromPart(content: IChatThinkingPart): string { return raw.trim(); } -function getToolInvocationIcon(toolId: string): ThemeIcon { +export function getToolInvocationIcon(toolId: string): ThemeIcon { const lowerToolId = toolId.toLowerCase(); if ( @@ -69,7 +69,7 @@ function getToolInvocationIcon(toolId: string): ThemeIcon { return Codicon.tools; } -function createThinkingIcon(icon: ThemeIcon): HTMLElement { +export function createThinkingIcon(icon: ThemeIcon): HTMLElement { const iconElement = $('span.chat-thinking-icon'); iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); return iconElement; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index eac8d7063fb73..8b38d206e06f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -84,7 +84,6 @@ export interface ICodeBlockData { readonly languageId: string; readonly codemapperUri?: URI; - readonly fromSubagent?: boolean; readonly vulns?: readonly IMarkdownVulnerability[]; readonly range?: Range; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css new file mode 100644 index 0000000000000..417be7917847b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Subagent-specific styles */ +.interactive-session .interactive-response .value .chat-thinking-fixed-mode.chat-subagent-part { + /* Collapsed + streaming: show only the last item with max-height */ + &.chat-used-context-collapsed .chat-used-context-list.chat-thinking-collapsible.chat-thinking-streaming { + max-height: var(--chat-subagent-last-item-height, 200px); + overflow: hidden; + display: block; + } + + /* Expanded: show all content, no max-height, no scrolling */ + .chat-used-context-list.chat-thinking-collapsible { + max-height: none; + overflow: visible; + } +} + +/* Subagent result collapsible section */ +.chat-subagent-result { + margin-top: 4px; + padding: 4px 8px; + + .chat-used-context-label { + cursor: pointer; + + .monaco-button { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s); + } + } + + .chat-subagent-result-content { + padding: 4px 8px 4px 20px; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + + p { + margin: 0; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 7947d601b762c..c541a208ec73b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -95,11 +95,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS ) { super(toolInvocation); - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } - const state = toolInvocation.state.get(); if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { throw new Error('Confirmation messages are missing'); 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 dffa3138a9b5b..150d5bd1beb7c 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 @@ -78,11 +78,6 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { partType: 'chatToolConfirmation', subtitle: typeof toolInvocation.originMessage === 'string' ? toolInvocation.originMessage : toolInvocation.originMessage?.value, }); - - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } } protected override additionalPrimaryActions() { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 553a1532a3027..5e079e248348a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -68,9 +68,6 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa super(); this.domNode = dom.$('.chat-tool-invocation-part'); - if (toolInvocation.fromSubAgent) { - this.domNode.classList.add('from-sub-agent'); - } if (toolInvocation.presentation === 'hidden') { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index f16c95fde1916..3b9e4582e94f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -41,6 +41,10 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { if (isComplete && this.toolIsConfirmed && this.toolInvocation.pastTenseMessage) { const key = this.getAnnouncementKey('complete'); const completionContent = this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + if (!this.hasMeaningfulContent(completionContent)) { + return document.createElement('div'); + } const shouldAnnounce = this.toolInvocation.kind === 'toolInvocation' && this.hasMeaningfulContent(completionContent) ? this.computeShouldAnnounce(key) : false; const part = this.renderProgressContent(completionContent, shouldAnnounce); this._register(part); @@ -52,6 +56,11 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { const progress = progressObservable?.read(reader); const key = this.getAnnouncementKey('progress'); const progressContent = progress?.message ?? this.toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + if (!this.hasMeaningfulContent(progressContent)) { + dom.clearNode(container); + return; + } const shouldAnnounce = this.toolInvocation.kind === 'toolInvocation' && this.hasMeaningfulContent(progressContent) ? this.computeShouldAnnounce(key) : false; const part = reader.store.add(this.renderProgressContent(progressContent, shouldAnnounce)); dom.reset(container, part.domNode); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts index 11d0a6af7933b..389a8bacda677 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts @@ -62,6 +62,13 @@ export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { const streamingMessage = currentState.streamingMessage.read(reader); const displayMessage = streamingMessage ?? toolInvocation.invocationMessage; + // Don't render anything if there's no meaningful content + const messageText = typeof displayMessage === 'string' ? displayMessage : displayMessage.value; + if (!messageText || messageText.trim().length === 0) { + dom.clearNode(container); + return; + } + const content: IMarkdownString = typeof displayMessage === 'string' ? new MarkdownString().appendText(displayMessage) : displayMessage; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c59b8c329a2ca..ef2be62a64671 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -88,12 +88,14 @@ import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, Coll import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; +import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { ChatMarkdownDecorationsRenderer } from './chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; const $ = dom.$; @@ -743,6 +745,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, subAgentInvocationId?: string): ChatSubagentContentPart | undefined { + if (!renderedParts || renderedParts.length === 0) { + return undefined; + } + + // Search backwards for the most recent subagent part + for (let i = renderedParts.length - 1; i >= 0; i--) { + const part = renderedParts[i]; + if (part instanceof ChatSubagentContentPart) { + // If looking for a specific ID, return the part with that ID regardless of active state + if (subAgentInvocationId && part.subAgentInvocationId === subAgentInvocationId) { + return part; + } + // If no ID specified, only return active parts + if (!subAgentInvocationId && part.getIsActive()) { + return part; + } + } + } + + return undefined; + } + + private finalizeAllSubagentParts(templateData: IChatListItemTemplate): void { + if (!templateData.renderedParts) { + return; + } + + // Finalize all active subagent parts (there can be multiple parallel subagents) + for (const part of templateData.renderedParts) { + if (part instanceof ChatSubagentContentPart && part.getIsActive()) { + part.markAsInactive(); + } + } + } + + private handleSubagentToolGrouping(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, part: ChatToolInvocationPart, subagentId: string, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatSubagentContentPart { + // Finalize any active thinking part since subagent tools have their own grouping + this.finalizeCurrentThinkingPart(context, templateData); + + const lastSubagent = this.getSubagentPart(templateData.renderedParts, subagentId); + if (lastSubagent) { + // Append to existing subagent part with matching ID + // But skip the runSubagent tool itself - we only want child tools + if (toolInvocation.toolId !== RunSubagentTool.Id) { + lastSubagent.appendItem(part.domNode!, toolInvocation); + } + lastSubagent.addDisposable(part); + return lastSubagent; + } + + // Create a new subagent part - it will extract description/agentName/prompt and watch for completion + const subagentPart = this.instantiationService.createInstance(ChatSubagentContentPart, subagentId, toolInvocation, context, this.chatContentMarkdownRenderer); + // Don't append the runSubagent tool itself - its description is already shown in the title + // Only append child tools (those with subAgentInvocationId) + if (toolInvocation.toolId !== RunSubagentTool.Id) { + subagentPart.appendItem(part.domNode!, toolInvocation); + } + subagentPart.addDisposable(part); + subagentPart.addDisposable(subagentPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + return subagentPart; + } + private finalizeCurrentThinkingPart(context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): void { const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (!lastThinking) { @@ -1361,6 +1434,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 122fc37518e1f..8f91967ab7f52 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -563,15 +563,6 @@ } } -.interactive-item-container .value .from-sub-agent { - &.chat-tool-invocation-part, - &.chat-confirmation-widget, - &.chat-terminal-confirmation-widget, - &.chat-codeblock-pill-widget { - margin-left: 18px; - } -} - .interactive-item-container .value > .rendered-markdown li > p { margin: 0; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 99b140e691209..7a79e81edc774 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -151,7 +151,6 @@ export interface IChatMarkdownContent { kind: 'markdownContent'; content: IMarkdownString; inlineReferences?: Record; - fromSubagent?: boolean; } export interface IChatTreeData { @@ -449,14 +448,14 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; - readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; readonly source: ToolDataSource; readonly toolId: string; readonly toolCallId: string; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; readonly state: IObservable; generatedTitle?: string; @@ -707,7 +706,7 @@ export interface IToolResultOutputDetailsSerialized { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; @@ -718,7 +717,7 @@ export interface IChatToolInvocationSerialized { toolCallId: string; toolId: string; source: ToolDataSource; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; generatedTitle?: string; kind: 'toolInvocationSerialized'; } @@ -737,6 +736,14 @@ export interface IChatPullRequestContent { kind: 'pullRequest'; } +export interface IChatSubagentToolInvocationData { + kind: 'subagent'; + description?: string; + agentName?: string; + prompt?: string; + result?: string; +} + export interface IChatTodoListContent { kind: 'todoList'; sessionId: string; @@ -990,11 +997,43 @@ export interface IChatSessionStats { removed: number; } -export interface IChatSessionTiming { +export type IChatSessionTiming = { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted: number | undefined; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded: number | undefined; +}; + +interface ILegacyChatSessionTiming { startTime: number; endTime?: number; } +export function convertLegacyChatSessionTiming(timing: IChatSessionTiming | ILegacyChatSessionTiming): IChatSessionTiming { + if (hasKey(timing, { created: true })) { + return timing; + } + return { + created: timing.startTime, + lastRequestStarted: timing.startTime, + lastRequestEnded: timing.endTime, + }; +} + export const enum ResponseModelState { Pending, Complete, @@ -1007,7 +1046,8 @@ export interface IChatDetail { sessionResource: URI; title: string; lastMessageDate: number; - timing: IChatSessionTiming; + // Also support old timing format for backwards compatibility with persisted data + timing: IChatSessionTiming | ILegacyChatSessionTiming; isActive: boolean; stats?: IChatSessionStats; lastResponseState: ResponseModelState; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index e515c29b76d8c..97c2637ef077e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -377,7 +377,11 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { startTime: entry.lastMessageDate }, + timing: entry.timing ?? { + created: entry.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: entry.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -393,7 +397,11 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, + timing: metadata.timing ?? { + created: metadata.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: metadata.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 76a9b34869810..94126a5ffcf9f 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService } from './chatService/chatService.js'; +import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -73,6 +73,7 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } + export interface IChatSessionItem { resource: URI; label: string; @@ -81,10 +82,7 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: { - startTime: number; - endTime?: number; - }; + timing: IChatSessionTiming; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 8fecdb4ebf87e..eb8349b4e1072 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1802,10 +1802,14 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastResponse = this._requests.at(-1)?.response; + const lastRequest = this._requests.at(-1); + const lastResponse = lastRequest?.response; + const lastRequestStarted = lastRequest?.timestamp; + const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; return { - startTime: this._timestamp, - endTime: lastResponse?.completedAt ?? lastResponse?.timestamp + created: this._timestamp, + lastRequestStarted, + lastRequestEnded, }; } 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 b5515039ffe9d..6a24a622c8cc4 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -6,15 +6,14 @@ import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; -import { localize } from '../../../../../../nls.js'; -import { ConfirmedReason, IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; +import { ConfirmedReason, IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult, ToolDataSource } from '../../tools/languageModelToolsService.js'; export interface IStreamingToolCallOptions { toolCallId: string; toolId: string; toolData: IToolData; - fromSubAgent?: boolean; + subagentInvocationId?: string; chatRequestId?: string; } @@ -28,12 +27,12 @@ export class ChatToolInvocation implements IChatToolInvocation { public presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; public source: ToolDataSource; - public readonly fromSubAgent: boolean | undefined; + public readonly subAgentInvocationId: string | undefined; public parameters: unknown; public generatedTitle?: string; public readonly chatRequestId?: string; - public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; private readonly _progress = observableValue<{ message?: string | IMarkdownString; progress: number | undefined }>(this, { progress: 0 }); private readonly _state: ISettableObservable; @@ -51,21 +50,19 @@ export class ChatToolInvocation implements IChatToolInvocation { * Use this when the tool call is beginning to stream partial input from the LM. */ public static createStreaming(options: IStreamingToolCallOptions): ChatToolInvocation { - return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.fromSubAgent, undefined, true, options.chatRequestId); + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, undefined, true, options.chatRequestId); } constructor( preparedInvocation: IPreparedToolInvocation | undefined, toolData: IToolData, public readonly toolCallId: string, - fromSubAgent: boolean | undefined, + subAgentInvocationId: string | undefined, parameters: unknown, isStreaming: boolean = false, chatRequestId?: string ) { - const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${toolData.displayName}"`); - const invocationMessage = preparedInvocation?.invocationMessage ?? defaultMessage; - this.invocationMessage = invocationMessage; + this.invocationMessage = preparedInvocation?.invocationMessage ?? ''; this.pastTenseMessage = preparedInvocation?.pastTenseMessage; this.originMessage = preparedInvocation?.originMessage; this.confirmationMessages = preparedInvocation?.confirmationMessages; @@ -73,7 +70,7 @@ export class ChatToolInvocation implements IChatToolInvocation { this.toolSpecificData = preparedInvocation?.toolSpecificData; this.toolId = toolData.id; this.source = toolData.source; - this.fromSubAgent = fromSubAgent; + this.subAgentInvocationId = subAgentInvocationId; this.parameters = parameters; this.chatRequestId = chatRequestId; @@ -278,7 +275,7 @@ export class ChatToolInvocation implements IChatToolInvocation { toolSpecificData: this.toolSpecificData, toolCallId: this.toolCallId, toolId: this.toolId, - fromSubAgent: this.fromSubAgent, + subAgentInvocationId: this.subAgentInvocationId, generatedTitle: this.generatedTitle, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 63ac4c99c214b..1465a8d5c5465 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,12 +665,13 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing = session instanceof ChatModel ? + const timing: IChatSessionTiming = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - startTime: session.creationDate, - endTime: lastMessageDate + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, }; return { diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 16d66aa198249..36f2fb547bc03 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -149,7 +149,10 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; - isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + */ + subAgentInvocationId?: string; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 1ce37155cb463..00942076e330c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -6,7 +6,8 @@ import type { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { URI } from '../../../../../../base/common/uri.js'; import { PromptsType } from '../promptTypes.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, getPromptFileDefaultLocation } from './promptFileLocations.js'; +import { getPromptFileDefaultLocations, IPromptSourceFolder, PromptFileSource } from './promptFileLocations.js'; +import { PromptsStorage } from '../service/promptsService.js'; /** * Configuration helper for the `reusable prompts` feature. @@ -58,6 +59,11 @@ export namespace PromptsConfig { */ export const MODE_LOCATION_KEY = 'chat.modeFilesLocations'; + /** + * Configuration key for the locations of skill folders. + */ + export const SKILLS_LOCATION_KEY = 'chat.agentSkillsLocations'; + /** * Configuration key for prompt file suggestions. */ @@ -85,7 +91,7 @@ export namespace PromptsConfig { /** * Get value of the `reusable prompt locations` configuration setting. - * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}. + * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}, {@link SKILLS_LOCATION_KEY}. */ export function getLocationsValue(configService: IConfigurationService, type: PromptsType): Record | undefined { const key = getPromptFileLocationsConfigKey(type); @@ -119,29 +125,34 @@ export namespace PromptsConfig { /** * Gets list of source folders for prompt files. - * Defaults to {@link PROMPT_DEFAULT_SOURCE_FOLDER}, {@link INSTRUCTIONS_DEFAULT_SOURCE_FOLDER} or {@link MODE_DEFAULT_SOURCE_FOLDER}. + * Defaults to {@link PROMPT_DEFAULT_SOURCE_FOLDER}, {@link INSTRUCTIONS_DEFAULT_SOURCE_FOLDER}, {@link MODE_DEFAULT_SOURCE_FOLDER} or {@link SKILLS_LOCATION_KEY}. */ - export function promptSourceFolders(configService: IConfigurationService, type: PromptsType): string[] { + export function promptSourceFolders(configService: IConfigurationService, type: PromptsType): IPromptSourceFolder[] { const value = getLocationsValue(configService, type); - const defaultSourceFolder = getPromptFileDefaultLocation(type); + const defaultSourceFolders = getPromptFileDefaultLocations(type); // note! the `value &&` part handles the `undefined`, `null`, and `false` cases if (value && (typeof value === 'object')) { - const paths: string[] = []; + const paths: IPromptSourceFolder[] = []; + const defaultFolderPathsSet = new Set(defaultSourceFolders.map(f => f.path)); - // if the default source folder is not explicitly disabled, add it - if (value[defaultSourceFolder] !== false) { - paths.push(defaultSourceFolder); + // add default source folders that are not explicitly disabled + for (const defaultFolder of defaultSourceFolders) { + if (value[defaultFolder.path] !== false) { + paths.push(defaultFolder); + } } // copy all the enabled paths to the result list for (const [path, enabledValue] of Object.entries(value)) { - // we already added the default source folder, so skip it - if ((enabledValue === false) || (path === defaultSourceFolder)) { + // we already added the default source folders, so skip them + if ((enabledValue === false) || defaultFolderPathsSet.has(path)) { continue; } - paths.push(path); + // determine location type in the general case + const storage = isTildePath(path) ? PromptsStorage.user : PromptsStorage.local; + paths.push({ path, source: storage === PromptsStorage.local ? PromptFileSource.ConfigPersonal : PromptFileSource.ConfigWorkspace, storage }); } return paths; @@ -211,6 +222,8 @@ export function getPromptFileLocationsConfigKey(type: PromptsType): string { return PromptsConfig.PROMPT_LOCATIONS_KEY; case PromptsType.agent: return PromptsConfig.MODE_LOCATION_KEY; + case PromptsType.skill: + return PromptsConfig.SKILLS_LOCATION_KEY; default: throw new Error('Unknown prompt type'); } @@ -244,3 +257,14 @@ export function asBoolean(value: unknown): boolean | undefined { return undefined; } + +/** + * Helper to check if a path starts with tilde (user home). + * Supports both Unix-style (`~/`) and Windows-style (`~\`) paths. + * + * @param path - path to check + * @returns `true` if the path starts with `~/` or `~\` + */ +export function isTildePath(path: string): boolean { + return path.startsWith('~/') || path.startsWith('~\\'); +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 5240b09f7c929..fb143dcf2be36 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../../base/common/path.js'; import { PromptsType } from '../promptTypes.js'; +import { PromptsStorage } from '../service/promptsService.js'; /** * File extension for the reusable prompt files. @@ -27,6 +28,11 @@ export const LEGACY_MODE_FILE_EXTENSION = '.chatmode.md'; */ export const AGENT_FILE_EXTENSION = '.agent.md'; +/** + * Skill file name (case insensitive). + */ +export const SKILL_FILENAME = 'SKILL.md'; + /** * Copilot custom instructions file name. */ @@ -54,20 +60,76 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; export const AGENTS_SOURCE_FOLDER = '.github/agents'; /** - * Default agent skills workspace source folders. + * Tracks where prompt files originate from. + */ +export enum PromptFileSource { + GitHubWorkspace = 'github-workspace', + CopilotPersonal = 'copilot-personal', + ClaudePersonal = 'claude-personal', + ClaudeWorkspace = 'claude-workspace', + ConfigWorkspace = 'config-workspace', + ConfigPersonal = 'config-personal', + ExtensionContribution = 'extension-contribution', + ExtensionAPI = 'extension-api', +} + +/** + * Prompt source folder path with source and storage type. + */ +export interface IPromptSourceFolder { + readonly path: string; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * Resolved prompt folder with source and storage type. + */ +export interface IResolvedPromptSourceFolder { + readonly uri: URI; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * Resolved prompt markdown file with source and storage type. */ -export const DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS = [ - { path: '.github/skills', type: 'github-workspace' }, - { path: '.claude/skills', type: 'claude-workspace' } -] as const; +export interface IResolvedPromptFile { + readonly fileUri: URI; + readonly source: PromptFileSource; + readonly storage: PromptsStorage; +} + +/** + * All default skill source folders (both workspace and user home). + */ +export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: '.github/skills', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.claude/skills', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.copilot/skills', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, + { path: '~/.claude/skills', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, +]; /** - * Default agent skills user home source folders. + * Default instructions source folders. */ -export const DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS = [ - { path: '.copilot/skills', type: 'copilot-personal' }, - { path: '.claude/skills', type: 'claude-personal' } -] as const; +export const DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; + +/** + * Default prompt source folders. + */ +export const DEFAULT_PROMPT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: PROMPT_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; + +/** + * Default agent source folders. + */ +export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ + { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, +]; /** * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). @@ -95,6 +157,10 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } + if (filename.toLowerCase() === SKILL_FILENAME.toLowerCase()) { + return PromptsType.skill; + } + // Check if it's a .md file in the .github/agents/ folder if (filename.endsWith('.md') && isInAgentsFolder(fileUri)) { return PromptsType.agent; @@ -118,19 +184,23 @@ export function getPromptFileExtension(type: PromptsType): string { return PROMPT_FILE_EXTENSION; case PromptsType.agent: return AGENT_FILE_EXTENSION; + case PromptsType.skill: + return SKILL_FILENAME; default: throw new Error('Unknown prompt type'); } } -export function getPromptFileDefaultLocation(type: PromptsType): string { +export function getPromptFileDefaultLocations(type: PromptsType): readonly IPromptSourceFolder[] { switch (type) { case PromptsType.instructions: - return INSTRUCTIONS_DEFAULT_SOURCE_FOLDER; + return DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS; case PromptsType.prompt: - return PROMPT_DEFAULT_SOURCE_FOLDER; + return DEFAULT_PROMPT_SOURCE_FOLDERS; case PromptsType.agent: - return AGENTS_SOURCE_FOLDER; + return DEFAULT_AGENT_SOURCE_FOLDERS; + case PromptsType.skill: + return DEFAULT_SKILL_SOURCE_FOLDERS; default: throw new Error('Unknown prompt type'); } @@ -160,6 +230,11 @@ export function getCleanPromptName(fileUri: URI): string { return basename(fileUri.path, '.md'); } + // For SKILL.md files (case insensitive), return 'SKILL' + if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { + return basename(fileUri.path, '.md'); + } + // For .md files in .github/agents/ folder, treat them as agent files if (fileName.endsWith('.md') && isInAgentsFolder(fileUri)) { return basename(fileUri.path, '.md'); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index b7e91c439c1d4..5fd5d7874646c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -499,13 +499,15 @@ export class PromptValidator { const allAttributeNames = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer] + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer], + [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description], }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; const recommendedAttributeNames = { [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), - [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)) + [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), }; export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 9ae26e570afe1..7da38b26d2252 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -11,6 +11,7 @@ import { LanguageSelector } from '../../../../../editor/common/languageSelector. export const PROMPT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snippets'; export const INSTRUCTIONS_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-instructions'; export const AGENT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-chat-modes'; // todo +export const SKILL_DOCUMENTATION_URL = 'https://aka.ms/vscode-agent-skills'; /** * Language ID for the reusable prompt syntax. @@ -27,13 +28,18 @@ export const INSTRUCTIONS_LANGUAGE_ID = 'instructions'; */ export const AGENT_LANGUAGE_ID = 'chatagent'; +/** + * Language ID for skill syntax. + */ +export const SKILL_LANGUAGE_ID = 'skill'; + /** * Prompt and instructions files language selector. */ -export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID]; +export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID]; /** - * The language id for for a prompts type. + * The language id for a prompts type. */ export function getLanguageIdForPromptsType(type: PromptsType): string { switch (type) { @@ -43,6 +49,8 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { return INSTRUCTIONS_LANGUAGE_ID; case PromptsType.agent: return AGENT_LANGUAGE_ID; + case PromptsType.skill: + return SKILL_LANGUAGE_ID; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -56,6 +64,8 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.instructions; case AGENT_LANGUAGE_ID: return PromptsType.agent; + case SKILL_LANGUAGE_ID: + return PromptsType.skill; default: return undefined; } @@ -68,7 +78,8 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u export enum PromptsType { instructions = 'instructions', prompt = 'prompt', - agent = 'agent' + agent = 'agent', + skill = 'skill' } export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 7d7f205164543..ccf1a4c42f4e3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -21,6 +21,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; export const CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT = 'onCustomAgentProvider'; export const INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT = 'onInstructionsProvider'; export const PROMPT_FILE_PROVIDER_ACTIVATION_EVENT = 'onPromptFileProvider'; +export const SKILL_PROVIDER_ACTIVATION_EVENT = 'onSkillProvider'; /** * Context for querying prompt files. @@ -192,7 +193,7 @@ export interface IChatPromptSlashCommand { export interface IAgentSkill { readonly uri: URI; - readonly type: 'personal' | 'project'; + readonly storage: PromptsStorage; readonly name: string; readonly description: string | undefined; } @@ -222,7 +223,7 @@ export interface IPromptsService extends IDisposable { /** * Get a list of prompt source folders based on the provided prompt type. */ - getSourceFolders(type: PromptsType): readonly IPromptPath[]; + getSourceFolders(type: PromptsType): Promise; /** * Validates if the provided command name is a valid prompt slash command. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 63a78454b07db..eac1705a2d044 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -8,7 +8,7 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; -import { dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { type ITextModel } from '../../../../../../editor/common/model.js'; @@ -28,11 +28,11 @@ import { IUserDataProfileService } from '../../../../../services/userDataProfile import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { getCleanPromptName } from '../config/promptFileLocations.js'; +import { getCleanPromptName, IResolvedPromptFile, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -83,6 +83,7 @@ export class PromptsService extends Disposable implements IPromptsService { [PromptsType.prompt]: new ResourceMap>(), [PromptsType.instructions]: new ResourceMap>(), [PromptsType.agent]: new ResourceMap>(), + [PromptsType.skill]: new ResourceMap>(), }; constructor( @@ -240,8 +241,8 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Shared helper to list prompt files from registered providers for a given type. */ - private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { - const result: IPromptPath[] = []; + private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { + const result: IExtensionPromptPath[] = []; // Activate extensions that might provide files for this type await this.extensionService.activateByEvent(activationEvent); @@ -300,7 +301,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { + private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); const contributedFiles = await Promise.all(this.contributedFiles[type].values()); @@ -317,19 +318,21 @@ export class PromptsService extends Disposable implements IPromptsService { return INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT; case PromptsType.prompt: return PROMPT_FILE_PROVIDER_ACTIVATION_EVENT; + case PromptsType.skill: + return SKILL_PROVIDER_ACTIVATION_EVENT; } } - public getSourceFolders(type: PromptsType): readonly IPromptPath[] { + public async getSourceFolders(type: PromptsType): Promise { const result: IPromptPath[] = []; if (type === PromptsType.agent) { - const folders = this.fileLocator.getAgentSourceFolder(); + const folders = await this.fileLocator.getAgentSourceFolders(); for (const uri of folders) { result.push({ uri, storage: PromptsStorage.local, type }); } } else { - for (const uri of this.fileLocator.getConfigBasedSourceFolders(type)) { + for (const uri of await this.fileLocator.getConfigBasedSourceFolders(type)) { result.push({ uri, storage: PromptsStorage.local, type }); } } @@ -663,8 +666,9 @@ export class PromptsService extends Disposable implements IPromptsService { let skippedMissingName = 0; let skippedDuplicateName = 0; let skippedParseFailed = 0; + let skippedNameMismatch = 0; - const process = async (uri: URI, skillType: string, scopeType: 'personal' | 'project'): Promise => { + const process = async (uri: URI, source: PromptFileSource, storage: PromptsStorage): Promise => { try { const parsedFile = await this.parseNew(uri, token); const name = parsedFile.header?.name; @@ -674,8 +678,18 @@ export class PromptsService extends Disposable implements IPromptsService { return; } + // Sanitize the name first (remove XML tags and truncate) const sanitizedName = this.truncateAgentSkillName(name, uri); + // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) + const skillFolderUri = dirname(uri); + const folderName = basename(skillFolderUri); + if (sanitizedName !== folderName) { + skippedNameMismatch++; + this.logger.error(`[findAgentSkills] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); + return; + } + // Check for duplicate names if (seenNames.has(sanitizedName)) { skippedDuplicateName++; @@ -685,20 +699,49 @@ export class PromptsService extends Disposable implements IPromptsService { seenNames.add(sanitizedName); const sanitizedDescription = this.truncateAgentSkillDescription(parsedFile.header?.description, uri); - result.push({ uri, type: scopeType, name: sanitizedName, description: sanitizedDescription } satisfies IAgentSkill); + result.push({ uri, storage, name: sanitizedName, description: sanitizedDescription } satisfies IAgentSkill); // Track skill type - skillTypes.set(skillType, (skillTypes.get(skillType) || 0) + 1); + skillTypes.set(source, (skillTypes.get(source) || 0) + 1); } catch (e) { skippedParseFailed++; this.logger.error(`[findAgentSkills] Failed to parse Agent skill file: ${uri}`, e instanceof Error ? e.message : String(e)); } }; - const workspaceSkills = await this.fileLocator.findAgentSkillsInWorkspace(token); - await Promise.all(workspaceSkills.map(({ uri, type }) => process(uri, type, 'project'))); - const userSkills = await this.fileLocator.findAgentSkillsInUserHome(token); - await Promise.all(userSkills.map(({ uri, type }) => process(uri, type, 'personal'))); + // Collect all skills with their metadata for sorting + const allSkills: Array = []; + const discoveredSkills = await this.fileLocator.findAgentSkills(token); + const extensionSkills = await this.getExtensionPromptFiles(PromptsType.skill, token); + allSkills.push(...discoveredSkills, ...extensionSkills.map((extPath) => ( + { + fileUri: extPath.uri, + storage: extPath.storage, + source: extPath.source === ExtensionAgentSourceType.contribution ? PromptFileSource.ExtensionContribution : PromptFileSource.ExtensionAPI + }))); + + const getPriority = (skill: IResolvedPromptFile | IExtensionPromptPath): number => { + if (skill.storage === PromptsStorage.local) { + return 0; // workspace + } + if (skill.storage === PromptsStorage.user) { + return 1; // personal + } + if (skill.source === PromptFileSource.ExtensionAPI) { + return 2; + } + if (skill.source === PromptFileSource.ExtensionContribution) { + return 3; + } + return 4; + }; + // Stable sort; we should keep order consistent to the order in the user's configuration object + allSkills.sort((a, b) => getPriority(a) - getPriority(b)); + + // Process sequentially to maintain order (important for duplicate name resolution) + for (const skill of allSkills) { + await process(skill.fileUri, skill.source, skill.storage); + } // Send telemetry about skill usage type AgentSkillsFoundEvent = { @@ -707,10 +750,13 @@ export class PromptsService extends Disposable implements IPromptsService { claudeWorkspace: number; copilotPersonal: number; githubWorkspace: number; - customPersonal: number; - customWorkspace: number; + configPersonal: number; + configWorkspace: number; + extensionContribution: number; + extensionAPI: number; skippedDuplicateName: number; skippedMissingName: number; + skippedNameMismatch: number; skippedParseFailed: number; }; @@ -720,10 +766,13 @@ export class PromptsService extends Disposable implements IPromptsService { claudeWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude workspace skills.' }; copilotPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Copilot personal skills.' }; githubWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of GitHub workspace skills.' }; - customPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom personal skills.' }; - customWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom workspace skills.' }; + configPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured personal skills.' }; + configWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured workspace skills.' }; + extensionContribution: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension contributed skills.' }; + extensionAPI: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension API provided skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; + skippedNameMismatch: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to name not matching folder name.' }; skippedParseFailed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to parse failures.' }; owner: 'pwang347'; comment: 'Tracks agent skill usage, discovery, and skipped files.'; @@ -731,14 +780,17 @@ export class PromptsService extends Disposable implements IPromptsService { this.telemetryService.publicLog2('agentSkillsFound', { totalSkillsFound: result.length, - claudePersonal: skillTypes.get('claude-personal') ?? 0, - claudeWorkspace: skillTypes.get('claude-workspace') ?? 0, - copilotPersonal: skillTypes.get('copilot-personal') ?? 0, - githubWorkspace: skillTypes.get('github-workspace') ?? 0, - customPersonal: skillTypes.get('custom-personal') ?? 0, - customWorkspace: skillTypes.get('custom-workspace') ?? 0, + claudePersonal: skillTypes.get(PromptFileSource.ClaudePersonal) ?? 0, + claudeWorkspace: skillTypes.get(PromptFileSource.ClaudeWorkspace) ?? 0, + copilotPersonal: skillTypes.get(PromptFileSource.CopilotPersonal) ?? 0, + githubWorkspace: skillTypes.get(PromptFileSource.GitHubWorkspace) ?? 0, + configWorkspace: skillTypes.get(PromptFileSource.ConfigWorkspace) ?? 0, + configPersonal: skillTypes.get(PromptFileSource.ConfigPersonal) ?? 0, + extensionContribution: skillTypes.get(PromptFileSource.ExtensionContribution) ?? 0, + extensionAPI: skillTypes.get(PromptFileSource.ExtensionAPI) ?? 0, skippedDuplicateName, skippedMissingName, + skippedNameMismatch, skippedParseFailed }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 0fb275283c0c9..015c6c5404083 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -7,11 +7,11 @@ import { URI } from '../../../../../../base/common/uri.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { getPromptFileLocationsConfigKey, PromptsConfig } from '../config/config.js'; +import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS, DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -57,8 +57,51 @@ export class PromptFilesLocator { } private async listFilesInUserData(type: PromptsType, token: CancellationToken): Promise { - const files = await this.resolveFilesAtLocation(this.userDataService.currentProfile.promptsHome, token); - return files.filter(file => getPromptFileType(file) === type); + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); + const absoluteLocations = type === PromptsType.skill + ? this.toAbsoluteLocationsForSkills(configuredLocations, userHome) + : this.toAbsoluteLocations(configuredLocations, userHome); + + const paths = new ResourceSet(); + for (const { uri, storage } of absoluteLocations) { + if (storage !== PromptsStorage.user) { + continue; + } + const files = await this.resolveFilesAtLocation(uri, type, token); + for (const file of files) { + if (getPromptFileType(file) === type) { + paths.add(file); + } + } + if (token.isCancellationRequested) { + return []; + } + } + + return [...paths]; + } + + /** + * Gets all source folder URIs for a prompt type (both workspace and user home). + * This is used for file watching to detect changes in all relevant locations. + */ + private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] { + const result: URI[] = []; + const { folders } = this.workspaceService.getWorkspace(); + const defaultFolders = getPromptFileDefaultLocations(type); + + for (const sourceFolder of defaultFolders) { + if (sourceFolder.storage === PromptsStorage.local) { + for (const workspaceFolder of folders) { + result.push(joinPath(workspaceFolder.uri, sourceFolder.path)); + } + } else if (sourceFolder.storage === PromptsStorage.user) { + result.push(joinPath(userHome, sourceFolder.path)); + } + } + + return result; } public createFilesUpdatedEvent(type: PromptsType): { readonly event: Event; dispose: () => void } { @@ -69,6 +112,7 @@ export class PromptFilesLocator { const key = getPromptFileLocationsConfigKey(type); let parentFolders = this.getLocalParentFolders(type); + let allSourceFolders: URI[] = []; const externalFolderWatchers = disposables.add(new DisposableStore()); const updateExternalFolderWatchers = () => { @@ -80,8 +124,20 @@ export class PromptFilesLocator { externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] })); } } + // Watch all source folders (including user home if applicable) + for (const folder of allSourceFolders) { + if (!this.workspaceService.getWorkspaceFolder(folder)) { + externalFolderWatchers.add(this.fileService.watch(folder, { recursive: true, excludes: [] })); + } + } }; - updateExternalFolderWatchers(); + + // Initialize source folders (async if type has userHome locations) + this.pathService.userHome().then(userHome => { + allSourceFolders = [...this.getSourceFoldersSync(type, userHome)]; + updateExternalFolderWatchers(); + }); + disposables.add(this.configService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(key)) { parentFolders = this.getLocalParentFolders(type); @@ -98,14 +154,19 @@ export class PromptFilesLocator { eventEmitter.fire(); return; } + if (allSourceFolders.some(folder => e.affects(folder))) { + eventEmitter.fire(); + return; + } })); disposables.add(this.fileService.watch(userDataFolder)); return { event: eventEmitter.event, dispose: () => disposables.dispose() }; } - public getAgentSourceFolder(): readonly URI[] { - return this.toAbsoluteLocations([AGENTS_SOURCE_FOLDER]); + public async getAgentSourceFolders(): Promise { + const userHome = await this.pathService.userHome(); + return this.toAbsoluteLocations(DEFAULT_AGENT_SOURCE_FOLDERS, userHome).map(l => l.uri); } /** @@ -120,9 +181,17 @@ export class PromptFilesLocator { * * @returns List of possible unambiguous prompt file folders. */ - public getConfigBasedSourceFolders(type: PromptsType): readonly URI[] { + public async getConfigBasedSourceFolders(type: PromptsType): Promise { + const userHome = await this.pathService.userHome(); const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); - const absoluteLocations = this.toAbsoluteLocations(configuredLocations); + + // No extra processing needed for skills, since we do not support glob patterns + if (type === PromptsType.skill) { + return this.toAbsoluteLocationsForSkills(configuredLocations, userHome).map(l => l.uri); + } + + // For other types, use the existing logic with glob pattern filtering + const absoluteLocations = this.toAbsoluteLocations(configuredLocations, userHome).map(l => l.uri); // locations in the settings can contain glob patterns so we need // to process them to get "clean" paths; the goal here is to have @@ -171,7 +240,7 @@ export class PromptFilesLocator { for (const { parent, filePattern } of this.getLocalParentFolders(type)) { const files = (filePattern === undefined) - ? await this.resolveFilesAtLocation(parent, token) // if the location does not contain a glob pattern, resolve the location directly + ? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly : await this.searchFilesInLocation(parent, filePattern, token); for (const file of files) { if (getPromptFileType(file) === type) { @@ -189,23 +258,41 @@ export class PromptFilesLocator { private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); if (type === PromptsType.agent) { - configuredLocations.push(AGENTS_SOURCE_FOLDER); + configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); } - const absoluteLocations = this.toAbsoluteLocations(configuredLocations); - return absoluteLocations.map(firstNonGlobParentAndPattern); + const absoluteLocations = type === PromptsType.skill ? + this.toAbsoluteLocationsForSkills(configuredLocations, undefined) : this.toAbsoluteLocations(configuredLocations, undefined); + return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } /** - * Converts locations defined in `settings` to absolute filesystem path URIs. + * Converts locations defined in `settings` to absolute filesystem path URIs with metadata. * This conversion is needed because locations in settings can be relative, * hence we need to resolve them based on the current workspace folders. + * If userHome is provided, paths starting with `~` will be expanded. Otherwise these paths are ignored. + * Preserves the type and location properties from the source folder definitions. */ - private toAbsoluteLocations(configuredLocations: readonly string[]): readonly URI[] { - const result = new ResourceSet(); + private toAbsoluteLocations(configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined): readonly IResolvedPromptSourceFolder[] { + const result: IResolvedPromptSourceFolder[] = []; + const seen = new ResourceSet(); const { folders } = this.workspaceService.getWorkspace(); - for (const configuredLocation of configuredLocations) { + for (const sourceFolder of configuredLocations) { + const configuredLocation = sourceFolder.path; try { + // Handle tilde paths when userHome is provided + if (isTildePath(configuredLocation)) { + // If userHome is not provided, we cannot resolve tilde paths so we skip this entry + if (userHome) { + const uri = joinPath(userHome, configuredLocation.substring(2)); + if (!seen.has(uri)) { + seen.add(uri); + result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage }); + } + } + continue; + } + if (isAbsolute(configuredLocation)) { let uri = URI.file(configuredLocation); const remoteAuthority = this.environmentService.remoteAuthority; @@ -214,11 +301,17 @@ export class PromptFilesLocator { // we need to convert it to a file URI with the remote authority uri = uri.with({ scheme: Schemas.vscodeRemote, authority: remoteAuthority }); } - result.add(uri); + if (!seen.has(uri)) { + seen.add(uri); + result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage }); + } } else { for (const workspaceFolder of folders) { const absolutePath = joinPath(workspaceFolder.uri, configuredLocation); - result.add(absolutePath); + if (!seen.has(absolutePath)) { + seen.add(absolutePath); + result.push({ uri: absolutePath, source: sourceFolder.source, storage: sourceFolder.storage }); + } } } } catch (error) { @@ -226,13 +319,42 @@ export class PromptFilesLocator { } } - return [...result]; + return result; + } + + /** + * Converts skill locations to absolute filesystem path URIs with restricted validation. + * Unlike toAbsoluteLocations(), this method enforces stricter rules for skills: + * - No glob patterns (performance concerns) + * - No absolute paths (portability concerns) + * - Only relative paths, tilde paths, and parent relative paths + * + * @param configuredLocations - Source folder definitions from configuration + * @param userHome - User home URI for tilde expansion (optional for workspace-only resolution) + * @returns List of resolved absolute URIs with metadata + */ + private toAbsoluteLocationsForSkills(configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined): readonly IResolvedPromptSourceFolder[] { + // Filter and validate skill paths before resolving + const validLocations = configuredLocations.filter(sourceFolder => { + const configuredLocation = sourceFolder.path; + if (!isValidSkillPath(configuredLocation)) { + this.logService.warn(`Skipping invalid skill path (glob patterns and absolute paths not supported): ${configuredLocation}`); + return false; + } + return true; + }); + + // Use the standard resolution logic for valid paths + return this.toAbsoluteLocations(validLocations, userHome); } /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. */ - private async resolveFilesAtLocation(location: URI, token: CancellationToken): Promise { + private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { + if (type === PromptsType.skill) { + return this.findAgentSkillsInFolder(location, token); + } try { const info = await this.fileService.resolve(location); if (info.isFile) { @@ -367,58 +489,34 @@ export class PromptFilesLocator { return undefined; } - private async findAgentSkillsInFolder(uri: URI, relativePath: string, token: CancellationToken): Promise { - const result = []; + private async findAgentSkillsInFolder(uri: URI, token: CancellationToken): Promise { try { - const stat = await this.fileService.resolve(joinPath(uri, relativePath)); - if (token.isCancellationRequested) { - return []; - } - if (stat.isDirectory && stat.children) { - for (const skillDir of stat.children) { - if (skillDir.isDirectory) { - const skillFile = joinPath(skillDir.resource, 'SKILL.md'); - if (await this.fileService.exists(skillFile)) { - result.push(skillFile); - } - } - } + return await this.searchFilesInLocation(uri, `*/${SKILL_FILENAME}`, token); + } catch (e) { + if (!isCancellationError(e)) { + this.logService.trace(`[PromptFilesLocator] Error searching for skills in ${uri.toString()}: ${e}`); } - } catch (error) { - // no such folder, return empty list return []; } - - return result; } /** - * Searches for skills in all default directories in the workspace. - * Each skill is stored in its own subdirectory with a SKILL.md file. + * Searches for skills in all configured locations. */ - public async findAgentSkillsInWorkspace(token: CancellationToken): Promise> { - const workspace = this.workspaceService.getWorkspace(); - const allResults: Array<{ uri: URI; type: string }> = []; - for (const folder of workspace.folders) { - for (const { path, type } of DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS) { - const results = await this.findAgentSkillsInFolder(folder.uri, path, token); - allResults.push(...results.map(uri => ({ uri, type }))); + public async findAgentSkills(token: CancellationToken): Promise { + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.skill); + const absoluteLocations = this.toAbsoluteLocationsForSkills(configuredLocations, userHome); + const allResults: IResolvedPromptFile[] = []; + + for (const { uri, source, storage } of absoluteLocations) { + if (token.isCancellationRequested) { + return []; } + const results = await this.findAgentSkillsInFolder(uri, token); + allResults.push(...results.map(uri => ({ fileUri: uri, source, storage }))); } - return allResults; - } - /** - * Searches for skills in all default directories in the home folder. - * Each skill is stored in its own subdirectory with a SKILL.md file. - */ - public async findAgentSkillsInUserHome(token: CancellationToken): Promise> { - const userHome = await this.pathService.userHome(); - const allResults: Array<{ uri: URI; type: string }> = []; - for (const { path, type } of DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS) { - const results = await this.findAgentSkillsInFolder(userHome, path, token); - allResults.push(...results.map(uri => ({ uri, type }))); - } return allResults; } } @@ -531,3 +629,33 @@ function firstNonGlobParentAndPattern(location: URI): { parent: URI; filePattern filePattern: segments.slice(i).join('/') }; } + + +/** + * Regex pattern string for validating skill paths. + * Skills only support: + * - Relative paths: someFolder, ./someFolder + * - User home paths: ~/folder or ~\folder + * - Parent relative paths for monorepos: ../folder + * + * NOT supported: + * - Absolute paths (portability issue) + * - Glob patterns with * or ** (performance issue) + * - Tilde without path separator (e.g., ~abc) + * - Empty or whitespace-only paths + * + * The regex validates: + * - Not a Windows absolute path (e.g., C:\) + * - Not starting with / (Unix absolute path) + * - If starts with ~, must be followed by / or \ + * - No glob pattern characters: * ? [ ] { } + * - At least one non-whitespace character + */ +export const VALID_SKILL_PATH_PATTERN = '^(?![A-Za-z]:[\\\\/])(?![\\\\/])(?!~(?![\\\\/]))(?!.*[*?\\[\\]{}]).*\\S.*$'; + +/** + * Validates if a path is allowed for skills configuration. + */ +export function isValidSkillPath(path: string): boolean { + return new RegExp(VALID_SKILL_PATH_PATTERN).test(path); +} 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 d5329cd630d23..6f9295fd7253a 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -40,8 +40,6 @@ import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomati import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; -export const RunSubagentToolId = 'runSubagent'; - const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. - Agents do not run async or in the background, you will wait for the agent\'s result. @@ -50,7 +48,7 @@ const BaseModelDescription = `Launch a new agent to handle complex, multi-step t - 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`; -interface IRunSubagentToolInputParams { +export interface IRunSubagentToolInputParams { prompt: string; description: string; agentName?: string; @@ -58,6 +56,8 @@ interface IRunSubagentToolInputParams { export class RunSubagentTool extends Disposable implements IToolImpl { + static readonly Id = 'runSubagent'; + readonly onDidUpdateToolData: Event; constructor( @@ -100,7 +100,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { 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 runSubagentToolData: IToolData = { - id: RunSubagentToolId, + id: RunSubagentTool.Id, toolReferenceName: VSCodeToolReference.runSubagent, icon: ThemeIcon.fromId(Codicon.organization.id), displayName: localize('tool.runSubagent.displayName', 'Run Subagent'), @@ -194,7 +194,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { if (part.kind === 'codeblockUri' && !inEdit) { inEdit = true; - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n') }); } model.acceptResponseProgress(request, part); @@ -204,7 +204,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } else if (part.kind === 'markdownContent') { if (inEdit) { - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); inEdit = false; } @@ -215,7 +215,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { }; if (modeTools) { - modeTools[RunSubagentToolId] = false; + modeTools[RunSubagentTool.Id] = false; modeTools[ManageTodoListToolToolId] = false; } @@ -229,7 +229,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - isSubagent: true, + subAgentInvocationId: invocation.chatStreamToolCallId, userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, @@ -249,7 +249,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } - return createToolSimpleTextResult(markdownParts.join('') || 'Agent completed with no output'); + const resultText = markdownParts.join('') || 'Agent completed with no output'; + + // Store result in toolSpecificData for serialization + if (invocation.toolSpecificData?.kind === 'subagent') { + invocation.toolSpecificData.result = resultText; + } + + return createToolSimpleTextResult(resultText); } catch (error) { const errorMessage = `Error invoking subagent: ${error instanceof Error ? error.message : 'Unknown error'}`; @@ -263,6 +270,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return { invocationMessage: args.description, + toolSpecificData: { + kind: 'subagent', + description: args.description, + agentName: args.agentName, + prompt: args.prompt, + }, }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index b2c10b4d433e0..d8f88d8d80271 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -24,7 +24,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { LanguageModelPartAudience } from '../languageModels.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -139,8 +139,8 @@ export interface IToolInvocation { /** * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups */ - fromSubAgent?: boolean; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + subAgentInvocationId?: string; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; modelId?: string; userSelectedTools?: UserSelectedTools; } @@ -308,7 +308,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: ToolInvocationPresentation; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; } export interface IToolImpl { @@ -376,7 +376,7 @@ export interface IBeginToolCallOptions { toolId: string; chatRequestId?: string; sessionResource?: URI; - fromSubAgent?: boolean; + subagentInvocationId?: string; } export const ILanguageModelToolsService = createDecorator('ILanguageModelToolsService'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 114f666d13545..bacf032abd9b5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; + const created = Date.now(); + const lastRequestEnded = created + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - changes: { files: 1, insertions: 10, deletions: 5, details: [] } + timing: { created, lastRequestStarted: created, lastRequestEnded }, + changes: { files: 1, insertions: 10, deletions: 5 } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); + assert.strictEqual(session.timing.created, created); + assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,9 +1521,10 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1552,9 +1553,10 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming = { - startTime: Date.UTC(2025, 11 /* December */, 10), - endTime: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 11 /* December */, 10), + lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1583,9 +1585,10 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1606,7 +1609,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use endTime (December 10) which is after the initial date + // Should use lastRequestEnded (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1614,8 +1617,10 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: undefined, }; const provider: IChatSessionItemProvider = { @@ -2054,8 +2059,15 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(): IChatSessionItem['timing'] { +function makeNewSessionTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); return { - startTime: Date.now(), + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f29f8f83327e5..d551277757baf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,8 +36,9 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - startTime: overrides.startTime ?? now, - endTime: overrides.endTime ?? now, + created: overrides.startTime ?? now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -73,8 +74,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.endTime || a.timing.startTime; - const bTime = b.timing.endTime || b.timing.startTime; + const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; + const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; return bTime - aTime; } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 7be0701efe294..8b88eae7f8298 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -18,11 +18,24 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; +function createTestTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); + return { + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, + }; +} + class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -319,7 +332,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), lastResponseState: ResponseModelState.Complete }]); @@ -343,7 +356,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -369,7 +382,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -377,7 +390,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -405,7 +418,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -435,7 +448,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -464,7 +477,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -493,7 +506,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -537,7 +550,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), stats: { added: 30, removed: 8, @@ -582,7 +595,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -593,7 +606,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for startTime when model exists', async () => { + test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -612,16 +625,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: modelTimestamp } + timing: createTestTiming({ created: modelTimestamp }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); }); - test('should use lastMessageDate for startTime when model does not exist', async () => { + test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -635,16 +648,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: lastMessageDate } + timing: createTestTiming({ created: lastMessageDate }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); }); - test('should set endTime from last response completedAt', async () => { + test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -664,12 +677,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: completedAt } + timing: createTestTiming({ lastRequestEnded: completedAt }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.endTime, completedAt); + assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); }); }); @@ -692,7 +705,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts new file mode 100644 index 0000000000000..5eca5ccea5b3d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-syntax */ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { renderFileWidgets } from '../../../../browser/widget/chatContentParts/chatInlineAnchorWidget.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatContentParts/chatMarkdownAnchorService.js'; + +suite('ChatInlineAnchorWidget Metadata Validation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let instantiationService: ReturnType; + let mockAnchorService: IChatMarkdownAnchorService; + + setup(() => { + disposables = store.add(new DisposableStore()); + instantiationService = workbenchInstantiationService(undefined, store); + + // Mock the anchor service + mockAnchorService = { + _serviceBrand: undefined, + register: () => ({ dispose: () => { } }), + lastFocusedAnchor: undefined + }; + + instantiationService.stub(IChatMarkdownAnchorService, mockAnchorService); + }); + + function createTestElement(linkText: string, href: string = 'file:///test.txt'): HTMLElement { + const container = mainWindow.document.createElement('div'); + const anchor = mainWindow.document.createElement('a'); + anchor.textContent = linkText; + anchor.setAttribute('data-href', href); + container.appendChild(anchor); + return container; + } + + test('renders widget for link with vscodeLinkType query parameter', () => { + const element = createTestElement('mySkill', 'file:///test.txt?vscodeLinkType=skill'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for link with vscodeLinkType query parameter'); + }); + + test('renders widget for empty link text', () => { + const element = createTestElement(''); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for empty link text'); + }); + + test('renders widget for vscodeLinkType=file', () => { + const element = createTestElement('document.txt', 'file:///path/to/document.txt?vscodeLinkType=file'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for vscodeLinkType=file'); + }); + + test('does not render widget for link without vscodeLinkType query parameter', () => { + const element = createTestElement('regular link text', 'file:///test.txt'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered for link without vscodeLinkType query parameter'); + }); + + test('does not render widget when URI scheme is missing', () => { + const element = createTestElement('mySkill', ''); // Empty href + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered when URI scheme is missing'); + }); + + test('renders widget with various vscodeLinkType values', () => { + const element = createTestElement('customName', 'file:///test.txt?vscodeLinkType=custom'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered for any vscodeLinkType value'); + }); + + test('handles vscodeLinkType with other query parameters', () => { + const element = createTestElement('skillName', 'file:///test.txt?other=value&vscodeLinkType=skill&another=param'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered when vscodeLinkType is among multiple query parameters'); + }); + + test('handles multiple links in same element', () => { + const container = mainWindow.document.createElement('div'); + + // Add link with vscodeLinkType query parameter + const validAnchor = mainWindow.document.createElement('a'); + validAnchor.textContent = 'validSkill'; + validAnchor.setAttribute('data-href', 'file:///valid.txt?vscodeLinkType=skill'); + container.appendChild(validAnchor); + + // Add link without vscodeLinkType query parameter + const invalidAnchor = mainWindow.document.createElement('a'); + invalidAnchor.textContent = 'regular text'; + invalidAnchor.setAttribute('data-href', 'file:///invalid.txt'); + container.appendChild(invalidAnchor); + + // Add empty link text + const emptyAnchor = mainWindow.document.createElement('a'); + emptyAnchor.textContent = ''; + emptyAnchor.setAttribute('data-href', 'file:///empty.txt'); + container.appendChild(emptyAnchor); + + renderFileWidgets(container, instantiationService, mockAnchorService, disposables); + + const widgets = container.querySelectorAll('.chat-inline-anchor-widget'); + assert.strictEqual(widgets.length, 2, 'Should render widgets for link with vscodeLinkType and empty link text only'); + }); + + test('uses link text as fileName in metadata', () => { + const element = createTestElement('myCustomFileName', 'file:///test.txt?vscodeLinkType=skill'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(widget, 'Widget should be rendered'); + // The link text becomes the fileName which is used as the label + const labelElement = widget?.querySelector('.icon-label'); + assert.ok(labelElement?.textContent?.includes('myCustomFileName'), 'Label should contain the link text as fileName'); + }); + + test('does not render widget for malformed URI', () => { + const element = createTestElement('mySkill', '://malformed-uri-without-scheme'); + renderFileWidgets(element, instantiationService, mockAnchorService, disposables); + + const widget = element.querySelector('.chat-inline-anchor-widget'); + assert.ok(!widget, 'Widget should not be rendered for malformed URI'); + }); +}); + diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 4cced4a16c470..026c88b2fa521 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,13 +10,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing = { startTime: 0 }; + readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts index 98302f3211ce9..36f585c08779d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts @@ -9,6 +9,14 @@ import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js' import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IConfigurationOverrides, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { IPromptSourceFolder } from '../../../../common/promptSyntax/config/promptFileLocations.js'; + +/** + * Helper to extract just the paths from IPromptSourceFolder array for testing. + */ +function getPaths(folders: IPromptSourceFolder[]): string[] { + return folders.map(f => f.path); +} /** * Mocked instance of {@link IConfigurationService}. @@ -22,7 +30,7 @@ function createMock(value: T): IConfigurationService { ); assert( - [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY].includes(key), + [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY, PromptsConfig.SKILLS_LOCATION_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -55,6 +63,26 @@ suite('PromptsConfig', () => { ); }); + test('undefined for skill', () => { + const configService = createMock(undefined); + + assert.strictEqual( + PromptsConfig.getLocationsValue(configService, PromptsType.skill), + undefined, + 'Must read correct value for skills.', + ); + }); + + test('null for skill', () => { + const configService = createMock(null); + + assert.strictEqual( + PromptsConfig.getLocationsValue(configService, PromptsType.skill), + undefined, + 'Must read correct value for skills.', + ); + }); + suite('object', () => { test('empty', () => { assert.deepStrictEqual( @@ -157,6 +185,50 @@ suite('PromptsConfig', () => { 'Must read correct value.', ); }); + + test('skill locations - empty', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({}), PromptsType.skill), + {}, + 'Must read correct value for skills.', + ); + }); + + test('skill locations - valid paths', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({ + '.github/skills': true, + '.claude/skills': true, + '/custom/skills/folder': true, + './relative/skills': true, + }), PromptsType.skill), + { + '.github/skills': true, + '.claude/skills': true, + '/custom/skills/folder': true, + './relative/skills': true, + }, + 'Must read correct skill locations.', + ); + }); + + test('skill locations - filters invalid entries', () => { + assert.deepStrictEqual( + PromptsConfig.getLocationsValue(createMock({ + '.github/skills': true, + '.claude/skills': '\t\n', + '/invalid/path': '', + '': true, + './valid/skills': true, + '\n': true, + }), PromptsType.skill), + { + '.github/skills': true, + './valid/skills': true, + }, + 'Must filter invalid skill locations.', + ); + }); }); }); @@ -165,7 +237,7 @@ suite('PromptsConfig', () => { const configService = createMock(undefined); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService, PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.prompt)), [], 'Must read correct value.', ); @@ -175,7 +247,7 @@ suite('PromptsConfig', () => { const configService = createMock(null); assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(configService, PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.prompt)), [], 'Must read correct value.', ); @@ -184,7 +256,7 @@ suite('PromptsConfig', () => { suite('object', () => { test('empty', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({}), PromptsType.prompt), + getPaths(PromptsConfig.promptSourceFolders(createMock({}), PromptsType.prompt)), ['.github/prompts'], 'Must read correct value.', ); @@ -192,7 +264,7 @@ suite('PromptsConfig', () => { test('only valid strings', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/root/.bashrc': true, '../../folder/.hidden-folder/config.xml': true, '/srv/www/Public_html/.htaccess': true, @@ -206,7 +278,7 @@ suite('PromptsConfig', () => { '/var/logs/app.01.05.error': true, '.GitHub/prompts': true, './.tempfile': true, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', '/root/.bashrc', @@ -229,7 +301,7 @@ suite('PromptsConfig', () => { test('filters out non valid entries', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '../assets/img/logo.v2.png': true, @@ -254,7 +326,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 2345, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', '../assets/img/logo.v2.png', @@ -271,7 +343,7 @@ suite('PromptsConfig', () => { test('only invalid or false values', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '../assets/IMG/logo.v2.png': '', @@ -282,7 +354,7 @@ suite('PromptsConfig', () => { '/var/data/datafile.2025-02-05.json': '\n', '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 7654, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '.github/prompts', ], @@ -292,7 +364,7 @@ suite('PromptsConfig', () => { test('filters out disabled default location', () => { assert.deepStrictEqual( - PromptsConfig.promptSourceFolders(createMock({ + getPaths(PromptsConfig.promptSourceFolders(createMock({ '/etc/hosts.backup': '\t\n\t', './run.tests.sh': '\v', '.github/prompts': false, @@ -317,7 +389,7 @@ suite('PromptsConfig', () => { '\f\f': true, '../lib/some_library.v1.0.1.so': '\r\n', '/dev/shm/.shared_resource': 853, - }), PromptsType.prompt), + }), PromptsType.prompt)), [ '../assets/img/logo.v2.png', '../.local/bin/script.sh', @@ -331,5 +403,126 @@ suite('PromptsConfig', () => { ); }); }); + + suite('skills', () => { + test('undefined returns empty array', () => { + const configService = createMock(undefined); + + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.skill)), + [], + 'Must return empty array for undefined config.', + ); + }); + + test('null returns empty array', () => { + const configService = createMock(null); + + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(configService, PromptsType.skill)), + [], + 'Must return empty array for null config.', + ); + }); + + test('empty object returns default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({}), PromptsType.skill)), + ['.github/skills', '.claude/skills', '~/.copilot/skills', '~/.claude/skills'], + 'Must return default skill folders.', + ); + }); + + test('includes custom skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '/custom/skills': true, + './local/skills': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/custom/skills', + './local/skills', + ], + 'Must include custom skill folders.', + ); + }); + + test('filters out disabled default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': false, + '/custom/skills': true, + }), PromptsType.skill)), + [ + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/custom/skills', + ], + 'Must filter out disabled .github/skills folder.', + ); + }); + + test('filters out all disabled default skill folders', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + '/only/custom/skills': true, + }), PromptsType.skill)), + [ + '/only/custom/skills', + ], + 'Must filter out all disabled default folders.', + ); + }); + + test('filters out invalid entries', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '/valid/skills': true, + '/invalid/path': '\t\n', + '': true, + './another/valid': true, + '\n': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/valid/skills', + './another/valid', + ], + 'Must filter out invalid entries.', + ); + }); + + test('includes all default folders when explicitly enabled', () => { + assert.deepStrictEqual( + getPaths(PromptsConfig.promptSourceFolders(createMock({ + '.github/skills': true, + '.claude/skills': true, + '~/.copilot/skills': true, + '~/.claude/skills': true, + '/extra/skills': true, + }), PromptsType.skill)), + [ + '.github/skills', + '.claude/skills', + '~/.copilot/skills', + '~/.claude/skills', + '/extra/skills', + ], + 'Must include all default folders.', + ); + }); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index 38d354c19a010..fb2852ca71dbc 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { getPromptFileType, getCleanPromptName } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { getPromptFileType, getCleanPromptName, isPromptOrInstructionsFile } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; suite('promptFileLocations', function () { @@ -62,6 +62,21 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/README.md'); assert.strictEqual(getPromptFileType(uri), undefined); }); + + test('SKILL.md (uppercase) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/SKILL.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); + + test('skill.md (lowercase) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/skill.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); + + test('Skill.md (mixed case) should be recognized as skill', () => { + const uri = URI.file('/workspace/.github/skills/test/Skill.md'); + assert.strictEqual(getPromptFileType(uri), PromptsType.skill); + }); }); suite('getCleanPromptName', () => { @@ -104,5 +119,38 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/test.txt'); assert.strictEqual(getCleanPromptName(uri), 'test.txt'); }); + + test('removes .md extension for SKILL.md (uppercase)', () => { + const uri = URI.file('/workspace/.github/skills/test/SKILL.md'); + assert.strictEqual(getCleanPromptName(uri), 'SKILL'); + }); + + test('removes .md extension for skill.md (lowercase)', () => { + const uri = URI.file('/workspace/.github/skills/test/skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'skill'); + }); + + test('removes .md extension for Skill.md (mixed case)', () => { + const uri = URI.file('/workspace/.github/skills/test/Skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'Skill'); + }); + }); + + suite('isPromptOrInstructionsFile', () => { + test('SKILL.md files should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/skills/test/SKILL.md')), true); + }); + + test('skill.md (lowercase) should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/skills/myskill/skill.md')), true); + }); + + test('Skill.md (mixed case) should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/skills/Skill.md')), true); + }); + + test('regular .md files should return false', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index f71149b121891..c46dd40f5b005 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -39,7 +39,7 @@ export class MockPromptsService implements IPromptsService { listPromptFiles(_type: any): Promise { throw new Error('Not implemented'); } listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getSourceFolders(_type: any): readonly any[] { throw new Error('Not implemented'); } + getSourceFolders(_type: any): Promise { throw new Error('Not implemented'); } isValidSlashCommandName(_command: string): boolean { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvePromptSlashCommand(command: string, _token: CancellationToken): Promise { throw new Error('Not implemented'); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 55845c7bcc19a..230ace54a1d35 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -6,8 +6,10 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; +import { relativePath } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -41,7 +43,7 @@ import { PromptsService } from '../../../../common/promptSyntax/service/promptsS import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; import { IPathService } from '../../../../../../services/path/common/pathService.js'; -import { ISearchService } from '../../../../../../services/search/common/search.js'; +import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { IDefaultAccountService } from '../../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IDefaultAccount } from '../../../../../../../base/common/defaultAccount.js'; @@ -116,7 +118,38 @@ suite('PromptsService', () => { } as IPathService; instaService.stub(IPathService, pathService); - instaService.stub(ISearchService, {}); + instaService.stub(ISearchService, { + async fileSearch(query: IFileQuery) { + // mock the search service - recursively find files matching pattern + const findFilesInLocation = async (location: URI, results: URI[] = []): Promise => { + try { + const resolve = await fileService.resolve(location); + if (resolve.isFile) { + results.push(resolve.resource); + } else if (resolve.isDirectory && resolve.children) { + for (const child of resolve.children) { + await findFilesInLocation(child.resource, results); + } + } + } catch (error) { + // folder doesn't exist + } + return results; + }; + + const results: IFileMatch[] = []; + for (const folderQuery of query.folderQueries) { + const allFiles = await findFilesInLocation(folderQuery.folder); + for (const resource of allFiles) { + const pathInFolder = relativePath(folderQuery.folder, resource) ?? ''; + if (query.filePattern === undefined || match(query.filePattern, pathInFolder)) { + results.push({ resource }); + } + } + } + return { results, messages: [] }; + } + }); service = disposables.add(instaService.createInstance(PromptsService)); instaService.stub(IPromptsService, service); @@ -1060,6 +1093,264 @@ suite('PromptsService', () => { }); }); + suite('listPromptFiles - skills', () => { + teardown(() => { + sinon.restore(); + }); + + test('should list skill files from workspace', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-workspace'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/skill1/SKILL.md`, + contents: [ + '---', + 'name: "Skill 1"', + 'description: "First skill"', + '---', + 'Skill 1 content', + ], + }, + { + path: `${rootFolder}/.claude/skills/skill2/SKILL.md`, + contents: [ + '---', + 'name: "Skill 2"', + 'description: "Second skill"', + '---', + 'Skill 2 content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const skill1 = result.find(s => s.uri.path.includes('skill1')); + assert.ok(skill1, 'Should find skill1'); + assert.strictEqual(skill1.type, PromptsType.skill); + assert.strictEqual(skill1.storage, PromptsStorage.local); + + const skill2 = result.find(s => s.uri.path.includes('skill2')); + assert.ok(skill2, 'Should find skill2'); + assert.strictEqual(skill2.type, PromptsType.skill); + assert.strictEqual(skill2.storage, PromptsStorage.local); + }); + + test('should list skill files from user home', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-user-home'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + { + path: '/home/user/.claude/skills/claude-personal/SKILL.md', + contents: [ + '---', + 'name: "Claude Personal Skill"', + 'description: "A Claude personal skill"', + '---', + 'Claude personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const personalSkills = result.filter(s => s.storage === PromptsStorage.user); + assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); + + const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); + assert.ok(copilotSkill, 'Should find copilot personal skill'); + + const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); + assert.ok(claudeSkill, 'Should find claude personal skill'); + }); + + test('should not list skills when not in skill folder structure', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const rootFolderName = 'no-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create files in non-skill locations + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/SKILL.md`, + contents: [ + '---', + 'name: "Not a skill"', + '---', + 'This is in prompts folder, not skills', + ], + }, + { + path: `${rootFolder}/SKILL.md`, + contents: [ + '---', + 'name: "Root skill"', + '---', + 'This is in root, not skills folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 0, 'Should not find any skills in non-skill locations'); + }); + + test('should handle mixed workspace and user home skills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'mixed-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + // Workspace skills + { + path: `${rootFolder}/.github/skills/workspace-skill/SKILL.md`, + contents: [ + '---', + 'name: "Workspace Skill"', + 'description: "A workspace skill"', + '---', + 'Workspace skill content', + ], + }, + // User home skills + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const workspaceSkills = result.filter(s => s.storage === PromptsStorage.local); + const userSkills = result.filter(s => s.storage === PromptsStorage.user); + + assert.strictEqual(workspaceSkills.length, 1, 'Should find 1 workspace skill'); + assert.strictEqual(userSkills.length, 1, 'Should find 1 user skill'); + }); + + test('should respect disabled default paths via config', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Disable .github/skills, only .claude/skills should be searched + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': true, + }); + + const rootFolderName = 'disabled-default-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/github-skill/SKILL.md`, + contents: [ + '---', + 'name: "GitHub Skill"', + 'description: "Should NOT be found"', + '---', + 'This skill is in a disabled folder', + ], + }, + { + path: `${rootFolder}/.claude/skills/claude-skill/SKILL.md`, + contents: [ + '---', + 'name: "Claude Skill"', + 'description: "Should be found"', + '---', + 'This skill is in an enabled folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find only 1 skill (from enabled folder)'); + assert.ok(result[0].uri.path.includes('.claude/skills'), 'Should only find skill from .claude/skills'); + assert.ok(!result[0].uri.path.includes('.github/skills'), 'Should not find skill from disabled .github/skills'); + }); + + test('should expand tilde paths in custom locations', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Add a tilde path as custom location + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': false, + '~/my-custom-skills': true, + }); + + const rootFolderName = 'tilde-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // The mock user home is /home/user, so ~/my-custom-skills should resolve to /home/user/my-custom-skills + await mockFiles(fileService, [ + { + path: '/home/user/my-custom-skills/custom-skill/SKILL.md', + contents: [ + '---', + 'name: "Custom Skill"', + 'description: "A skill from tilde path"', + '---', + 'Skill content from ~/my-custom-skills', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find 1 skill from tilde-expanded path'); + assert.ok(result[0].uri.path.includes('/home/user/my-custom-skills'), 'Path should be expanded from tilde'); + }); + }); + suite('listPromptFiles - extensions', () => { test('Contributed prompt file', async () => { @@ -1466,6 +1757,125 @@ suite('PromptsService', () => { registered.dispose(); }); + test('Skill file provider', async () => { + const skillUri = URI.parse('file://extensions/my-extension/mySkill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: skillUri.path, + contents: [ + '---', + 'name: "My Custom Skill"', + 'description: "A custom skill from provider"', + '---', + 'Custom skill content.', + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: skillUri + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + const actual = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const providerSkill = actual.find(i => i.uri.toString() === skillUri.toString()); + + assert.ok(providerSkill, 'Provider skill should be found'); + assert.strictEqual(providerSkill!.uri.toString(), skillUri.toString()); + assert.strictEqual(providerSkill!.storage, PromptsStorage.extension); + assert.strictEqual(providerSkill!.source, ExtensionAgentSourceType.provider); + + registered.dispose(); + + // After disposal, the skill should no longer be listed + const actualAfterDispose = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const foundAfterDispose = actualAfterDispose.find(i => i.uri.toString() === skillUri.toString()); + assert.strictEqual(foundAfterDispose, undefined); + }); + + test('Skill file provider with isEditable flag', async () => { + const readonlySkillUri = URI.parse('file://extensions/my-extension/readonlySkill/SKILL.md'); + const editableSkillUri = URI.parse('file://extensions/my-extension/editableSkill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: readonlySkillUri.path, + contents: [ + '---', + 'name: "Readonly Skill"', + 'description: "A readonly skill"', + '---', + 'Readonly skill content.', + ] + }, + { + path: editableSkillUri.path, + contents: [ + '---', + 'name: "Editable Skill"', + 'description: "An editable skill"', + '---', + 'Editable skill content.', + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: readonlySkillUri, + isEditable: false + }, + { + uri: editableSkillUri, + isEditable: true + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + // Spy on updateReadonly to verify it's called correctly + const filesConfigService = instaService.get(IFilesConfigurationService); + const updateReadonlySpy = sinon.spy(filesConfigService, 'updateReadonly'); + + // List prompt files to trigger the readonly check + await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + // Verify updateReadonly was called only for the non-editable skill + assert.strictEqual(updateReadonlySpy.callCount, 1, 'updateReadonly should be called once'); + assert.ok(updateReadonlySpy.calledWith(readonlySkillUri, true), 'updateReadonly should be called with readonly skill URI and true'); + + const actual = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + const readonlySkill = actual.find(i => i.uri.toString() === readonlySkillUri.toString()); + const editableSkill = actual.find(i => i.uri.toString() === editableSkillUri.toString()); + + assert.ok(readonlySkill, 'Readonly skill should be found'); + assert.ok(editableSkill, 'Editable skill should be found'); + + registered.dispose(); + }); + suite('findAgentSkills', () => { teardown(() => { sinon.restore(); @@ -1516,6 +1926,7 @@ suite('PromptsService', () => { test('should find skills in workspace and user home', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'agent-skills-test'; const rootFolder = `/${rootFolderName}`; @@ -1524,9 +1935,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); // Create mock filesystem with skills in both .github/skills and .claude/skills + // Folder names must match the skill names exactly (per agentskills.io specification) await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/github-skill-1/SKILL.md`, + path: `${rootFolder}/.github/skills/GitHub Skill 1/SKILL.md`, contents: [ '---', 'name: "GitHub Skill 1"', @@ -1536,7 +1948,7 @@ suite('PromptsService', () => { ], }, { - path: `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`, + path: `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`, contents: [ '---', 'name: "Claude Skill 1"', @@ -1559,7 +1971,7 @@ suite('PromptsService', () => { contents: ['This is not a skill'], }, { - path: '/home/user/.claude/skills/personal-skill-1/SKILL.md', + path: '/home/user/.claude/skills/Personal Skill 1/SKILL.md', contents: [ '---', 'name: "Personal Skill 1"', @@ -1573,7 +1985,7 @@ suite('PromptsService', () => { contents: ['Not a skill file'], }, { - path: '/home/user/.copilot/skills/copilot-skill-1/SKILL.md', + path: '/home/user/.copilot/skills/Copilot Skill 1/SKILL.md', contents: [ '---', 'name: "Copilot Skill 1"', @@ -1590,36 +2002,37 @@ suite('PromptsService', () => { assert.strictEqual(result.length, 4, 'Should find 4 skills total'); // Check project skills (both from .github/skills and .claude/skills) - const projectSkills = result.filter(skill => skill.type === 'project'); + const projectSkills = result.filter(skill => skill.storage === PromptsStorage.local); assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills'); const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1'); assert.ok(githubSkill1, 'Should find GitHub skill 1'); assert.strictEqual(githubSkill1.description, 'A GitHub skill for testing'); - assert.strictEqual(githubSkill1.uri.path, `${rootFolder}/.github/skills/github-skill-1/SKILL.md`); + assert.strictEqual(githubSkill1.uri.path, `${rootFolder}/.github/skills/GitHub Skill 1/SKILL.md`); const claudeSkill1 = projectSkills.find(skill => skill.name === 'Claude Skill 1'); assert.ok(claudeSkill1, 'Should find Claude skill 1'); assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing'); - assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`); + assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`); // Check personal skills - const personalSkills = result.filter(skill => skill.type === 'personal'); + const personalSkills = result.filter(skill => skill.storage === PromptsStorage.user); assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); const personalSkill1 = personalSkills.find(skill => skill.name === 'Personal Skill 1'); assert.ok(personalSkill1, 'Should find Personal Skill 1'); assert.strictEqual(personalSkill1.description, 'A personal skill for testing'); - assert.strictEqual(personalSkill1.uri.path, '/home/user/.claude/skills/personal-skill-1/SKILL.md'); + assert.strictEqual(personalSkill1.uri.path, '/home/user/.claude/skills/Personal Skill 1/SKILL.md'); const copilotSkill1 = personalSkills.find(skill => skill.name === 'Copilot Skill 1'); assert.ok(copilotSkill1, 'Should find Copilot Skill 1'); assert.strictEqual(copilotSkill1.description, 'A Copilot skill for testing'); - assert.strictEqual(copilotSkill1.uri.path, '/home/user/.copilot/skills/copilot-skill-1/SKILL.md'); + assert.strictEqual(copilotSkill1.uri.path, '/home/user/.copilot/skills/Copilot Skill 1/SKILL.md'); }); test('should handle parsing errors gracefully', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'skills-error-test'; const rootFolder = `/${rootFolderName}`; @@ -1628,9 +2041,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); // Create mock filesystem with malformed skill file in .github/skills + // Folder names must match the skill names exactly await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/valid-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/Valid Skill/SKILL.md`, contents: [ '---', 'name: "Valid Skill"', @@ -1656,7 +2070,7 @@ suite('PromptsService', () => { assert.ok(result, 'Should return results even with parsing errors'); assert.strictEqual(result.length, 1, 'Should find 1 valid skill'); assert.strictEqual(result[0].name, 'Valid Skill'); - assert.strictEqual(result[0].type, 'project'); + assert.strictEqual(result[0].storage, PromptsStorage.local); }); test('should return empty array when no skills found', async () => { @@ -1679,6 +2093,7 @@ suite('PromptsService', () => { test('should truncate long names and descriptions', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'truncation-test'; const rootFolder = `/${rootFolderName}`; @@ -1687,11 +2102,13 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); const longName = 'A'.repeat(100); // Exceeds 64 characters + const truncatedName = 'A'.repeat(64); // Expected after truncation const longDescription = 'B'.repeat(1500); // Exceeds 1024 characters await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/long-skill/SKILL.md`, + // Folder name must match the truncated skill name + path: `${rootFolder}/.github/skills/${truncatedName}/SKILL.md`, contents: [ '---', `name: "${longName}"`, @@ -1712,6 +2129,7 @@ suite('PromptsService', () => { test('should remove XML tags from name and description', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'xml-test'; const rootFolder = `/${rootFolderName}`; @@ -1719,9 +2137,10 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + // Folder name must match the sanitized skill name (with XML tags removed) await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/xml-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/Skill with XML tags/SKILL.md`, contents: [ '---', 'name: "Skill with XML tags"', @@ -1742,6 +2161,7 @@ suite('PromptsService', () => { test('should handle both truncation and XML removal', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); const rootFolderName = 'combined-test'; const rootFolder = `/${rootFolderName}`; @@ -1750,11 +2170,13 @@ suite('PromptsService', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); const longNameWithXml = '

' + 'A'.repeat(100) + '

'; // Exceeds 64 chars and has XML + const truncatedName = 'A'.repeat(64); // Expected after XML removal and truncation const longDescWithXml = '
' + 'B'.repeat(1500) + '
'; // Exceeds 1024 chars and has XML + // Folder name must match the fully sanitized skill name await mockFiles(fileService, [ { - path: `${rootFolder}/.github/skills/combined-skill/SKILL.md`, + path: `${rootFolder}/.github/skills/${truncatedName}/SKILL.md`, contents: [ '---', `name: "${longNameWithXml}"`, @@ -1777,5 +2199,318 @@ suite('PromptsService', () => { assert.ok(!result[0].description?.includes('>'), 'Description should not contain XML tags'); assert.strictEqual(result[0].description?.length, 1024, 'Description should be truncated to 1024 characters'); }); + + test('should skip duplicate skill names and keep first by priority', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'duplicate-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skills with duplicate names in different locations + // Workspace skill should be kept (higher priority), user skill should be skipped + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Duplicate Skill/SKILL.md`, + contents: [ + '---', + 'name: "Duplicate Skill"', + 'description: "Workspace version"', + '---', + 'Workspace skill content', + ], + }, + { + path: '/home/user/.copilot/skills/Duplicate Skill/SKILL.md', + contents: [ + '---', + 'name: "Duplicate Skill"', + 'description: "User version - should be skipped"', + '---', + 'User skill content', + ], + }, + { + path: `${rootFolder}/.claude/skills/Unique Skill/SKILL.md`, + contents: [ + '---', + 'name: "Unique Skill"', + 'description: "A unique skill"', + '---', + 'Unique skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (duplicate skipped)'); + + const duplicateSkill = result.find(s => s.name === 'Duplicate Skill'); + assert.ok(duplicateSkill, 'Should find the duplicate skill'); + assert.strictEqual(duplicateSkill.description, 'Workspace version', 'Should keep workspace version (higher priority)'); + assert.strictEqual(duplicateSkill.storage, PromptsStorage.local, 'Should be from workspace'); + + const uniqueSkill = result.find(s => s.name === 'Unique Skill'); + assert.ok(uniqueSkill, 'Should find the unique skill'); + }); + + test('should prioritize skills by source: workspace > user > extension', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'priority-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skills from different sources with same name + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/Priority Skill/SKILL.md', + contents: [ + '---', + 'name: "Priority Skill"', + 'description: "User version"', + '---', + 'User skill content', + ], + }, + { + path: `${rootFolder}/.github/skills/Priority Skill/SKILL.md`, + contents: [ + '---', + 'name: "Priority Skill"', + 'description: "Workspace version - highest priority"', + '---', + 'Workspace skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find 1 skill (duplicates resolved by priority)'); + assert.strictEqual(result[0].description, 'Workspace version - highest priority', 'Workspace should win over user'); + assert.strictEqual(result[0].storage, PromptsStorage.local); + }); + + test('should skip skills where name does not match folder name', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'name-mismatch-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + // Folder name "wrong-folder-name" doesn't match skill name "Correct Skill Name" + path: `${rootFolder}/.github/skills/wrong-folder-name/SKILL.md`, + contents: [ + '---', + 'name: "Correct Skill Name"', + 'description: "This skill should be skipped due to name mismatch"', + '---', + 'Skill content', + ], + }, + { + // Folder name matches skill name + path: `${rootFolder}/.github/skills/Valid Skill/SKILL.md`, + contents: [ + '---', + 'name: "Valid Skill"', + 'description: "This skill should be found"', + '---', + 'Valid skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find only 1 skill (mismatched one skipped)'); + assert.strictEqual(result[0].name, 'Valid Skill', 'Should only find the valid skill'); + }); + + test('should skip skills with missing name attribute', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'missing-name-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/no-name-skill/SKILL.md`, + contents: [ + '---', + 'description: "This skill has no name attribute"', + '---', + 'Skill content without name', + ], + }, + { + path: `${rootFolder}/.github/skills/Valid Named Skill/SKILL.md`, + contents: [ + '---', + 'name: "Valid Named Skill"', + 'description: "This skill has a name"', + '---', + 'Valid skill content', + ], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 1, 'Should find only 1 skill (one without name skipped)'); + assert.strictEqual(result[0].name, 'Valid Named Skill', 'Should only find skill with name attribute'); + }); + + test('should include extension-provided skills in findAgentSkills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'extension-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const extensionSkillUri = URI.parse('file://extensions/my-extension/Extension Skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Create workspace skill and extension skill + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Workspace Skill/SKILL.md`, + contents: [ + '---', + 'name: "Workspace Skill"', + 'description: "A workspace skill"', + '---', + 'Workspace skill content', + ], + }, + { + path: extensionSkillUri.path, + contents: [ + '---', + 'name: "Extension Skill"', + 'description: "A skill from extension provider"', + '---', + 'Extension skill content', + ], + }, + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [{ uri: extensionSkillUri }]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (workspace + extension)'); + + const workspaceSkill = result.find(s => s.name === 'Workspace Skill'); + assert.ok(workspaceSkill, 'Should find workspace skill'); + assert.strictEqual(workspaceSkill.storage, PromptsStorage.local); + + const extensionSkill = result.find(s => s.name === 'Extension Skill'); + assert.ok(extensionSkill, 'Should find extension skill'); + assert.strictEqual(extensionSkill.storage, PromptsStorage.extension); + + registered.dispose(); + }); + + test('should include contributed skill files in findAgentSkills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'contributed-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/Contributed Skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' } + } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/Local Skill/SKILL.md`, + contents: [ + '---', + 'name: "Local Skill"', + 'description: "A local skill"', + '---', + 'Local skill content', + ], + }, + { + path: contributedSkillUri.path, + contents: [ + '---', + 'name: "Contributed Skill"', + 'description: "A contributed skill from extension"', + '---', + 'Contributed skill content', + ], + }, + ]); + + const registered = service.registerContributedFile( + PromptsType.skill, + contributedSkillUri, + extension, + 'Contributed Skill', + 'A contributed skill from extension' + ); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results'); + assert.strictEqual(result.length, 2, 'Should find 2 skills (local + contributed)'); + + const localSkill = result.find(s => s.name === 'Local Skill'); + assert.ok(localSkill, 'Should find local skill'); + assert.strictEqual(localSkill.storage, PromptsStorage.local); + + const contributedSkill = result.find(s => s.name === 'Contributed Skill'); + assert.ok(contributedSkill, 'Should find contributed skill'); + assert.strictEqual(contributedSkill.storage, PromptsStorage.extension); + + registered.dispose(); + + // After disposal, only local skill should remain + const resultAfterDispose = await service.findAgentSkills(CancellationToken.None); + assert.strictEqual(resultAfterDispose?.length, 1, 'Should find 1 skill after disposal'); + assert.strictEqual(resultAfterDispose?.[0].name, 'Local Skill'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts index c8ee911524fd9..cb408b18a05fb 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts @@ -167,16 +167,17 @@ export class MockFilesystem { */ private async ensureParentDirectories(dirUri: URI): Promise { if (!await this.fileService.exists(dirUri)) { - if (dirUri.path === '/') { - try { - await this.fileService.createFolder(dirUri); - this.createdFolders.push(dirUri); - } catch (error) { - throw new Error(`Failed to create directory '${dirUri.toString()}': ${error}.`); - } - } else { + // First ensure the parent directory exists (recursive call) + if (dirUri.path !== '/') { await this.ensureParentDirectories(dirname(dirUri)); } + // Then create this directory + try { + await this.fileService.createFolder(dirUri); + this.createdFolders.push(dirUri); + } catch (error) { + throw new Error(`Failed to create directory '${dirUri.toString()}': ${error}.`); + } } } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 884decc92dd97..c983f777c3d57 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -21,9 +21,10 @@ import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../.. import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; +import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { isValidGlob, PromptFilesLocator } from '../../../../common/promptSyntax/utils/promptFilesLocator.js'; +import { isValidGlob, isValidSkillPath, PromptFilesLocator } from '../../../../common/promptSyntax/utils/promptFilesLocator.js'; import { IMockFolder, MockFilesystem } from '../testUtils/mockFilesystem.js'; import { mockService } from './mock.js'; import { TestUserDataProfileService } from '../../../../../../test/common/workbenchTestServices.js'; @@ -45,7 +46,7 @@ function mockConfigService(value: T): IConfigurationService { } assert( - [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY].includes(key), + [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY, PromptsConfig.SKILLS_LOCATION_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -149,6 +150,15 @@ suite('PromptFilesLocator', () => { return { results, messages: [] }; } }); + instantiationService.stub(IPathService, { + userHome(options?: { preferLocal: boolean }): URI | Promise { + const uri = URI.file('/Users/legomushroom'); + if (options?.preferLocal) { + return uri; + } + return Promise.resolve(uri); + } + } as IPathService); const locator = instantiationService.createInstance(PromptFilesLocator); @@ -156,8 +166,11 @@ suite('PromptFilesLocator', () => { async listFiles(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { return locator.listFiles(type, storage, token); }, - getConfigBasedSourceFolders(type: PromptsType): readonly URI[] { - return locator.getConfigBasedSourceFolders(type); + async getConfigBasedSourceFolders(type: PromptsType): Promise { + return await locator.getConfigBasedSourceFolders(type); + }, + async findAgentSkills(token: CancellationToken) { + return await locator.findAgentSkills(token); }, async disposeAsync(): Promise { await mockFs.delete(); @@ -2349,6 +2362,473 @@ suite('PromptFilesLocator', () => { }); }); + suite('skills', () => { + suite('findAgentSkills', () => { + testT('finds skill files in configured locations', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'pptx', + children: [ + { + name: 'SKILL.md', + contents: '# PPTX Skill', + }, + ], + }, + { + name: 'excel', + children: [ + { + name: 'SKILL.md', + contents: '# Excel Skill', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/pptx/SKILL.md', + '/Users/legomushroom/repos/vscode/.claude/skills/excel/SKILL.md', + ], + 'Must find skill files.', + ); + await locator.disposeAsync(); + }); + + testT('ignores folders without SKILL.md', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'valid-skill', + children: [ + { + name: 'SKILL.md', + contents: '# Valid Skill', + }, + ], + }, + { + name: 'invalid-skill', + children: [ + { + name: 'readme.md', + contents: 'Not a skill file', + }, + ], + }, + { + name: 'another-invalid', + children: [ + { + name: 'index.js', + contents: 'console.log("not a skill")', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/valid-skill/SKILL.md', + ], + 'Must only find folders with SKILL.md.', + ); + await locator.disposeAsync(); + }); + + testT('returns empty array when no skills exist', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [], + 'Must return empty array when no skills exist.', + ); + await locator.disposeAsync(); + }); + + testT('returns empty array when skill folder does not exist', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], // empty filesystem + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [], + 'Must return empty array when folder does not exist.', + ); + await locator.disposeAsync(); + }); + + testT('finds skills across multiple workspace folders', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + [ + '/Users/legomushroom/repos/vscode', + '/Users/legomushroom/repos/node', + ], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'skill-a', + children: [ + { + name: 'SKILL.md', + contents: '# Skill A', + }, + ], + }, + ], + }, + { + name: '/Users/legomushroom/repos/node/.claude/skills', + children: [ + { + name: 'skill-b', + children: [ + { + name: 'SKILL.md', + contents: '# Skill B', + }, + ], + }, + ], + }, + ], + ); + + const skills = await locator.findAgentSkills(CancellationToken.None); + assertOutcome( + skills.map(s => s.fileUri), + [ + '/Users/legomushroom/repos/vscode/.claude/skills/skill-a/SKILL.md', + '/Users/legomushroom/repos/node/.claude/skills/skill-b/SKILL.md', + ], + 'Must find skills across all workspace folders.', + ); + await locator.disposeAsync(); + }); + }); + + suite('listFiles with PromptsType.skill', () => { + testT('does not list skills when location is disabled', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': false, + // disable other defaults + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode/.claude/skills', + children: [ + { + name: 'pptx', + children: [ + { + name: 'SKILL.md', + contents: '# PPTX Skill', + }, + ], + }, + ], + }, + ], + ); + + const files = await locator.listFiles(PromptsType.skill, PromptsStorage.local, CancellationToken.None); + assertOutcome( + files, + [], + 'Must not list skills when location is disabled.', + ); + await locator.disposeAsync(); + }); + }); + + suite('toAbsoluteLocationsForSkills path validation', () => { + testT('rejects glob patterns in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + 'skills/**': true, + 'skills/*': true, + '**/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [], + 'Must reject glob patterns in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('rejects absolute paths in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + '/absolute/path/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [], + 'Must reject absolute paths in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('accepts relative paths in skill paths via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + './my-skills': true, + 'custom/skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/my-skills', + '/Users/legomushroom/repos/vscode/custom/skills', + ], + 'Must accept relative paths in skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('accepts parent relative paths for monorepos via getConfigBasedSourceFolders', async () => { + const locator = await createPromptsLocator( + { + '../shared-skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/shared-skills', + ], + 'Must accept parent relative paths for monorepos.', + ); + await locator.disposeAsync(); + }); + + testT('accepts tilde paths for user home skills', async () => { + const locator = await createPromptsLocator( + { + '~/my-skills': true, + // disable defaults + '.github/skills': false, + '.claude/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/my-skills', + ], + 'Must accept tilde paths for user home skills.', + ); + await locator.disposeAsync(); + }); + }); + + suite('getConfigBasedSourceFolders for skills', () => { + testT('returns source folders without glob processing', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + 'custom-skills': true, + // explicitly disable other defaults we don't want for this test + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + [ + '/Users/legomushroom/repos/vscode', + '/Users/legomushroom/repos/node', + ], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/.claude/skills', + '/Users/legomushroom/repos/node/.claude/skills', + '/Users/legomushroom/repos/vscode/custom-skills', + '/Users/legomushroom/repos/node/custom-skills', + ], + 'Must return skill source folders without glob processing.', + ); + await locator.disposeAsync(); + }); + + testT('filters out invalid skill paths from source folders', async () => { + const locator = await createPromptsLocator( + { + '.claude/skills': true, + 'skills/**': true, // glob - should be filtered out + '/absolute/skills': true, // absolute - should be filtered out + // explicitly disable other defaults we don't want for this test + '.github/skills': false, + '~/.copilot/skills': false, + '~/.claude/skills': false, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + '/Users/legomushroom/repos/vscode/.claude/skills', + ], + 'Must filter out invalid skill paths.', + ); + await locator.disposeAsync(); + }); + + testT('includes default skill source folders from defaults', async () => { + const locator = await createPromptsLocator( + { + 'custom-skills': true, + }, + ['/Users/legomushroom/repos/vscode'], + [], + ); + + const folders = await locator.getConfigBasedSourceFolders(PromptsType.skill); + assertOutcome( + folders, + [ + // defaults + '/Users/legomushroom/repos/vscode/.github/skills', + '/Users/legomushroom/repos/vscode/.claude/skills', + '/Users/legomushroom/.copilot/skills', + '/Users/legomushroom/.claude/skills', + // custom + '/Users/legomushroom/repos/vscode/custom-skills', + ], + 'Must include default skill source folders.', + ); + await locator.disposeAsync(); + }); + }); + }); + suite('isValidGlob', () => { testT('valid patterns', async () => { const globs = [ @@ -2424,6 +2904,155 @@ suite('PromptFilesLocator', () => { }); }); + suite('isValidSkillPath', () => { + testT('accepts relative paths', async () => { + const validPaths = [ + 'someFolder', + './someFolder', + 'my-skills', + './my-skills', + 'folder/subfolder', + './folder/subfolder', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (relative path).`, + ); + } + }); + + testT('accepts user home paths', async () => { + const validPaths = [ + '~/folder', + '~/.copilot/skills', + '~/.claude/skills', + '~/my-skills', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (user home path).`, + ); + } + }); + + testT('accepts parent relative paths for monorepos', async () => { + const validPaths = [ + '../folder', + '../shared-skills', + '../../common/skills', + '../parent/folder', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted as a valid skill path (parent relative path).`, + ); + } + }); + + testT('rejects absolute paths', async () => { + const invalidPaths = [ + // Unix absolute paths + '/Users/username/skills', + '/absolute/path', + '/usr/local/skills', + // Windows absolute paths + 'C:\\Users\\skills', + 'D:/skills', + 'c:\\folder', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (absolute paths not supported for portability).`, + ); + } + }); + + testT('rejects tilde paths without path separator', async () => { + const invalidPaths = [ + '~abc', + '~skills', + '~.config', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (tilde must be followed by / or \\).`, + ); + } + }); + + testT('rejects glob patterns', async () => { + const invalidPaths = [ + 'skills/*', + 'skills/**', + '**/skills', + 'skills/*.md', + 'skills/**/*.md', + '{skill1,skill2}', + 'skill[1,2,3]', + 'skills?', + './skills/*', + '~/skills/**', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (glob patterns not supported for performance).`, + ); + } + }); + + testT('rejects empty or whitespace paths', async () => { + const invalidPaths = [ + '', + ' ', + '\t', + '\n', + ]; + + for (const path of invalidPaths) { + assert.strictEqual( + isValidSkillPath(path), + false, + `'${path}' must be rejected (empty or whitespace only).`, + ); + } + }); + + testT('handles paths with spaces', async () => { + const validPaths = [ + 'my skills', + './my skills/folder', + '~/my skills', + '../shared skills', + ]; + + for (const path of validPaths) { + assert.strictEqual( + isValidSkillPath(path), + true, + `'${path}' must be accepted (paths with spaces are valid).`, + ); + } + }); + }); + suite('getConfigBasedSourceFolders', () => { testT('gets unambiguous list of folders', async () => { const locator = await createPromptsLocator( @@ -2445,7 +3074,7 @@ suite('PromptFilesLocator', () => { ); assertOutcome( - locator.getConfigBasedSourceFolders(PromptsType.prompt), + await locator.getConfigBasedSourceFolders(PromptsType.prompt), [ '/Users/legomushroom/repos/vscode/.github/prompts', '/Users/legomushroom/repos/prompts/.github/prompts', diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 01dcc338f8010..83235b8fea7e4 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -107,7 +107,7 @@ declare module 'vscode' { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, toolCallId: string, isError?: boolean); @@ -359,7 +359,7 @@ declare module 'vscode' { * @param toolName The name of the tool being invoked. * @param streamData Optional initial streaming data with partial arguments. */ - beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): void; + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData & { subagentInvocationId?: string }): void; /** * Update the streaming data for a tool invocation that was started with `beginToolInvocation`. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 6b6c670a5273e..39861c8e49852 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -93,7 +93,11 @@ declare module 'vscode' { */ readonly editedFileEvents?: ChatRequestEditedFileEvent[]; - readonly isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + * Pass this to tool invocations when calling tools from within a subagent context. + */ + readonly subAgentInvocationId?: string; } export enum ChatRequestEditedFileEventKind { @@ -234,9 +238,9 @@ declare module 'vscode' { chatInteractionId?: string; terminalCommand?: string; /** - * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ - fromSubAgent?: boolean; + subAgentInvocationId?: string; } export interface LanguageModelToolInvocationPrepareOptions { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index ac6ade0f4137b..016b45c29160e 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -26,6 +26,25 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ @@ -52,6 +71,86 @@ declare module 'vscode' { // #endregion } + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { + readonly id: string; + + /** + * Unregisters the controller, disposing of its associated chat session items. + */ + dispose(): void; + + /** + * Managed collection of chat session items + */ + readonly items: ChatSessionItemCollection; + + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item is archived by the editor + * + * TODO: expose archive state on the item too? + */ + readonly onDidArchiveChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; + } + export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -91,15 +190,42 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * The times at which session started and ended + * Whether the chat session has been archived. + */ + archived?: boolean; + + /** + * Timing information for the chat session */ timing?: { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime: number; + startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; @@ -268,18 +394,6 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. *