Compute instructions references when making request, show why attachment was added (#251497)

* Compute intsructions references when making request, show why attachment was added

* remove unused getMetadata
This commit is contained in:
Martin Aeschlimann
2025-06-16 00:19:28 -07:00
committed by GitHub
parent 5a501dcde9
commit a1d87f0f27
15 changed files with 149 additions and 1221 deletions
@@ -8,9 +8,7 @@ import { Emitter } from '../../../../base/common/event.js';
import { basename } from '../../../../base/common/resources.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { IChatRequestFileEntry, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry } from '../common/chatVariableEntries.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachments.js';
import { IChatRequestFileEntry, IChatRequestVariableEntry, isPromptFileVariableEntry } from '../common/chatVariableEntries.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
@@ -28,22 +26,17 @@ export interface IChatAttachmentChangeEvent {
export class ChatAttachmentModel extends Disposable {
private readonly _promptInstructions: ChatPromptAttachmentsCollection;
private readonly _attachments = new Map<string, IChatRequestVariableEntry>();
private _onDidChange = this._register(new Emitter<IChatAttachmentChangeEvent>());
readonly onDidChange = this._onDidChange.event;
constructor(
@IInstantiationService instaService: IInstantiationService,
@IFileService private readonly fileService: IFileService,
@IDialogService private readonly dialogService: IDialogService,
@ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService,
) {
super();
this._promptInstructions = this._register(instaService.createInstance(ChatPromptAttachmentsCollection));
this._register(this._promptInstructions.onUpdate(variable => this._onDidChange.fire({ updated: [variable], deleted: [], added: [] })));
}
get attachments(): ReadonlyArray<IChatRequestVariableEntry> {
@@ -75,23 +68,6 @@ export class ChatAttachmentModel extends Disposable {
}
}
addPromptFiles(promptFiles: readonly URI[]): void {
const variables = promptFiles.map(uri => toPromptFileVariableEntry(uri, true));
this.addContext(...variables);
}
hasPromptFiles(languageId: string): boolean {
return this._promptInstructions.hasPromptFiles(languageId);
}
/**
* Get the list of all prompt instruction attachment variables, including all
* nested child references of each attachment explicitly attached by user.
*/
getPromptFileVariables(): Promise<readonly IChatRequestVariableEntry[]> {
return this._promptInstructions.getAttachments();
}
addFolder(uri: URI) {
this.addContext({
kind: 'directory',
@@ -105,7 +81,6 @@ export class ChatAttachmentModel extends Disposable {
if (clearStickyAttachments) {
const deleted = Array.from(this._attachments.keys());
this._attachments.clear();
this._promptInstructions.clear();
this._onDidChange.fire({ deleted, added: [], updated: [] });
} else {
const deleted: string[] = [];
@@ -114,6 +89,7 @@ export class ChatAttachmentModel extends Disposable {
const entry = this._attachments.get(id);
if (entry && !isPromptFileVariableEntry(entry)) {
this._attachments.delete(id);
deleted.push(id);
}
}
this._onDidChange.fire({ deleted, added: [], updated: [] });
@@ -143,9 +119,6 @@ export class ChatAttachmentModel extends Disposable {
if (item) {
this._attachments.delete(id);
deleted.push(id);
if (isPromptFileVariableEntry(item)) {
this._promptInstructions.remove(item);
}
}
}
@@ -154,9 +127,6 @@ export class ChatAttachmentModel extends Disposable {
if (!oldItem) {
this._attachments.set(item.id, item);
added.push(item);
if (isPromptFileVariableEntry(item)) {
this._promptInstructions.add([item]);
}
} else if (!equals(oldItem, item)) {
this._attachments.set(item.id, item);
updated.push(item);
@@ -1,144 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancelablePromise, createCancelablePromise } from '../../../../../base/common/async.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../base/common/map.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { IChatRequestVariableEntry, IPromptFileVariableEntry, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js';
import { IPromptParserResult, IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
/**
* Model for a collection of prompt instruction attachments.
* Starts
*/
export class ChatPromptAttachmentsCollection extends Disposable {
/**
* Event that fires then this model is updated.
*
* See {@linkcode onUpdate}.
*/
protected _onUpdate = this._register(new Emitter<IPromptFileVariableEntry>());
/**
* Subscribe to the `onUpdate` event.
*/
public onUpdate = this._onUpdate.event;
/**
* List of all prompt instruction attachments.
*/
private _attachments = new ResourceMap<CancelablePromise<IPromptParserResult>>();
constructor(
@ILanguageService private readonly languageService: ILanguageService,
@IModelService private readonly modelService: IModelService,
@IPromptsService private readonly promptsService: IPromptsService,
) {
super();
}
/**
* Check if any of the attachments is a prompt file.
*/
public hasPromptFiles(promptFileLanguageId: string): boolean {
const hasLanguage = (uri: URI) => {
const model = this.modelService.getModel(uri);
const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri);
return languageId === promptFileLanguageId;
};
for (const uri of this._attachments.keys()) {
if (hasLanguage(uri)) {
return true;
}
}
return false;
}
/**
* Get the list of all prompt instruction attachment variables, including all
* nested child references of each attachment explicitly attached by user.
*/
public async getAttachments(): Promise<readonly IChatRequestVariableEntry[]> {
const result = [];
const attachments = [...this._attachments.values()];
for (const parseResultPromise of attachments) {
const parseResult = await parseResultPromise;
// the usual URIs list of prompt instructions is `bottom-up`, therefore
// we do the same here - first add all child references of the model
for (const uri of parseResult.allValidReferences) {
result.push(toPromptFileVariableEntry(uri, false));
}
// then add the root reference of the model itself
result.push(toPromptFileVariableEntry(parseResult.uri, true));
}
return result;
}
/**
* Add prompt instruction attachment instances
*/
public add(entries: IPromptFileVariableEntry[]) {
for (const entry of entries) {
const uri = entry.value;
// if already exists, nothing to do
if (this._attachments.has(uri)) {
continue;
}
const parseResult = createCancelablePromise(token => this.promptsService.parse(uri, token));
parseResult.then(() => {
this._onUpdate.fire(entry);
}).catch((error) => {
// if parsing fails, we still create an attachment model
// to allow the user to see the error and fix it
console.error(`Failed to parse prompt file ${uri.toString()}:`, error);
});
this._attachments.set(uri, parseResult);
}
}
/**
* Remove a prompt instruction attachment instancs
*/
public remove(entry: IPromptFileVariableEntry): this {
const uri = entry.value;
const attachment = this._attachments.get(uri);
if (attachment) {
this._attachments.delete(uri);
attachment.cancel();
}
return this;
}
/**
* Clear all prompt instruction attachments.
*/
public clear(): this {
for (const attachment of this._attachments.values()) {
attachment.cancel();
}
this._attachments.clear();
return this;
}
public override dispose(): void {
super.dispose();
this.clear(); // disposes of all attachments
}
}
@@ -25,7 +25,7 @@ import { IOpenerService, OpenInternalOptions } from '../../../../platform/opener
import { IThemeService, FolderThemeIcon } from '../../../../platform/theme/common/themeService.js';
import { IResourceLabel, ResourceLabels, IFileLabelOptions } from '../../../browser/labels.js';
import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js';
import { IChatRequestPasteVariableEntry, IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, ISCMHistoryItemVariableEntry, OmittedState } from '../common/chatVariableEntries.js';
import { IChatRequestPasteVariableEntry, IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, ISCMHistoryItemVariableEntry, OmittedState } from '../common/chatVariableEntries.js';
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { basename, dirname } from '../../../../base/common/path.js';
@@ -54,6 +54,8 @@ import { getHistoryItemEditorTitle, getHistoryItemHoverContent } from '../../scm
import { ILanguageModelToolsService, ToolSet } from '../common/languageModelToolsService.js';
import { Iterable } from '../../../../base/common/iterator.js';
import { getCleanPromptName } from '../common/promptSyntax/config/promptFileLocations.js';
import { IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { PromptsType } from '../common/promptSyntax/promptTypes.js';
abstract class AbstractChatAttachmentWidget extends Disposable {
public readonly element: HTMLElement;
@@ -481,7 +483,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
constructor(
resource: URI,
attachment: IChatRequestVariableEntry,
attachment: IPromptFileVariableEntry,
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
container: HTMLElement,
@@ -490,7 +492,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
@ICommandService commandService: ICommandService,
@IOpenerService openerService: IOpenerService,
@ILabelService private readonly labelService: ILabelService,
@ILanguageService private readonly languageService: ILanguageService,
@IPromptsService private readonly promptService: IPromptsService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
@@ -498,7 +500,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
this.hintElement = dom.append(this.element, dom.$('span.prompt-type'));
this.updateLabel(resource);
this.updateLabel(attachment);
this.instantiationService.invokeFunction(accessor => {
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource));
@@ -508,11 +510,12 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
this.attachClearButton();
}
private updateLabel(resource: URI) {
private updateLabel(attachment: IPromptFileVariableEntry) {
const resource = attachment.value;
const fileBasename = basename(resource.path);
const fileDirname = dirname(resource.path);
const friendlyName = `${fileBasename} ${fileDirname}`;
const isPrompt = this.languageService.guessLanguageIdByFilepathOrFirstLine(resource) === 'prompt';
const isPrompt = this.promptService.getPromptFileType(resource) === PromptsType.prompt;
const ariaLabel = isPrompt
? localize('chat.promptAttachment', "Prompt file, {0}", friendlyName)
: localize('chat.instructionsAttachment', "Instructions attachment, {0}", friendlyName);
@@ -520,8 +523,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
? localize('prompt', "Prompt")
: localize('instructions', "Instructions");
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
const title = `${typeLabel} ${uriLabel}`;
const title = this.labelService.getUriLabel(resource) + (attachment.originLabel ? `\n${attachment.originLabel}` : '');
//const { topError } = this.promptFile;
this.element.classList.remove('warning', 'error');
@@ -101,7 +101,8 @@ import { ChatRelatedFiles } from './contrib/chatInputRelatedFilesContrib.js';
import { resizeImage } from './imageUtils.js';
import { IModelPickerDelegate, ModelPickerActionItem } from './modelPicker/modelPickerActionItem.js';
import { IModePickerDelegate, ModePickerActionItem } from './modelPicker/modePickerActionItem.js';
import { PROMPT_LANGUAGE_ID } from '../common/promptSyntax/promptTypes.js';
import { PromptsType } from '../common/promptSyntax/promptTypes.js';
import { IPromptsService } from '../common/promptSyntax/service/promptsService.js';
const $ = dom.$;
@@ -163,18 +164,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
readonly selectedToolsModel: ChatSelectedTools;
public async getAttachedAndImplicitContext(sessionId: string): Promise<ChatRequestVariableSet> {
public getAttachedAndImplicitContext(sessionId: string): ChatRequestVariableSet {
const contextArr = new ChatRequestVariableSet();
// get prompt file variables (instructions) and all rereferenced contents first
contextArr.add(... await this.attachmentModel.getPromptFileVariables());
// then add all other attachments (includes prompt file variables, but they will be ignored if already added)
contextArr.add(...this.attachmentModel.attachments);
if (this.implicitContext?.enabled && this.implicitContext.value) {
const implicitChatVariables = await this.implicitContext.toBaseEntries();
const implicitChatVariables = this.implicitContext.toBaseEntries();
contextArr.add(...implicitChatVariables);
}
return contextArr;
@@ -184,8 +181,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
* Check if the chat input part has any prompt file attachments.
*/
get hasPromptFileAttachments(): boolean {
// if prompt attached explicitly as a "prompt" attachment
return this._attachmentModel.hasPromptFiles(PROMPT_LANGUAGE_ID);
return this._attachmentModel.attachments.some(entry => {
return isPromptFileVariableEntry(entry) && entry.isRoot && this.promptsService.getPromptFileType(entry.value) === PromptsType.prompt;
});
}
private _indexOfLastAttachedContextDeletedWithKeyboard: number;
@@ -361,6 +359,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
@IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService,
@IChatEntitlementService private readonly entitlementService: IChatEntitlementService,
@IChatModeService private readonly chatModeService: IChatModeService,
@IPromptsService private readonly promptsService: IPromptsService,
) {
super();
this._onDidLoadInputState = this._register(new Emitter<any>());
@@ -54,7 +54,7 @@ import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'
import { ChatAgentLocation, ChatMode } from '../common/constants.js';
import { ILanguageModelToolsService, IToolData, ToolSet } from '../common/languageModelToolsService.js';
import { type TPromptMetadata } from '../common/promptSyntax/parsers/promptHeader/promptHeader.js';
import { IMetadata, IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { IPromptParserResult, IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { handleModeSwitch } from './actions/chatActions.js';
import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions } from './chat.js';
import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js';
@@ -69,6 +69,7 @@ import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js
import { MicrotaskDelay } from '../../../../base/common/symbols.js';
import { IChatRequestVariableEntry, ChatRequestVariableSet as ChatRequestVariableSet, isPromptFileVariableEntry, toPromptFileVariableEntry } from '../common/chatVariableEntries.js';
import { PromptsConfig } from '../common/promptSyntax/config/config.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
const $ = dom.$;
@@ -1207,24 +1208,24 @@ export class ChatWidget extends Disposable implements IChatWidget {
private _findPromptFileInContext(attachedContext: ChatRequestVariableSet): URI | undefined {
for (const item of attachedContext.asArray()) {
if (isPromptFileVariableEntry(item) && item.isRoot) {
if (isPromptFileVariableEntry(item) && item.isRoot && this.promptsService.getPromptFileType(item.value) === PromptsType.prompt) {
return IChatRequestVariableEntry.toUri(item);
}
}
return undefined;
}
private async _applyPromptFileIfSet(requestInput: { input: string; attachedContext: ChatRequestVariableSet }): Promise<IMetadata | undefined> {
private async _applyPromptFileIfSet(requestInput: { input: string; attachedContext: ChatRequestVariableSet }): Promise<IPromptParserResult | undefined> {
let metadata: IMetadata | undefined;
let parseResult: IPromptParserResult | undefined;
// first check if the input has a prompt slash command
const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart);
if (agentSlashPromptPart) {
metadata = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand);
if (metadata) {
parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None);
if (parseResult) {
// add the prompt file to the context, but not sticky
requestInput.attachedContext.add(toPromptFileVariableEntry(metadata.uri, true));
requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, true));
// remove the slash command from the input
requestInput.input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim();
@@ -1233,25 +1234,25 @@ export class ChatWidget extends Disposable implements IChatWidget {
// if not, check if the context contains a prompt file: This is the old workflow that we still support for legacy reasons
const uri = this._findPromptFileInContext(requestInput.attachedContext);
if (uri) {
metadata = await this.promptsService.getMetadata(uri);
parseResult = await this.promptsService.parse(uri, CancellationToken.None);
}
}
if (!metadata) {
if (!parseResult) {
return undefined;
}
if (!requestInput.input.trim()) {
// NOTE this is a prompt and therefore not localized
requestInput.input = `Follow instructions from ${basename(metadata.uri)}`;
requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()} )`;
}
const meta = metadata.metadata;
const meta = parseResult.metadata;
if (meta?.promptType === PromptsType.prompt) {
await this._applyPromptMetadata(meta);
}
return metadata;
return parseResult;
}
private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise<IChatResponseModel | undefined> {
@@ -1277,15 +1278,17 @@ export class ChatWidget extends Disposable implements IChatWidget {
const requestId = this.chatAccessibilityService.acceptRequest();
const requestInputs = {
input: !query ? editorValue : query.query,
attachedContext: await this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId),
attachedContext: this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId),
};
const isUserQuery = !query;
const instructionsEnabled = PromptsConfig.enabled(this.configurationService);
if (instructionsEnabled) {
// process the prompt command
await this._applyPromptFileIfSet(requestInputs);
await this.autoAttachInstructions(requestInputs.attachedContext);
await this._autoAttachInstructions(requestInputs.attachedContext);
await this._collectReferencedInstructions(requestInputs.attachedContext);
}
if (this.viewOptions.enableWorkingSet !== undefined && this.input.currentMode === ChatMode.Edit && !this.chatService.edits2Enabled) {
@@ -1614,22 +1617,46 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.inputPart.selectedToolsModel.enable(enabledToolSets, enabledTools, true);
}
private async _collectReferencedInstructions(attachedContext: ChatRequestVariableSet): Promise<void> {
for (const variable of attachedContext.asArray()) {
if (isPromptFileVariableEntry(variable)) {
const result = await this.promptsService.parse(variable.value, CancellationToken.None);
for (const ref of result.allValidReferences) {
const reason = localize('instruction.file.reason.referenced', 'Referenced by {0}', basename(variable.value));
attachedContext.add(toPromptFileVariableEntry(ref, true, reason));
}
}
}
}
/**
* Resolves instructions that have `include` metadata that can
* Resolves instructions that have `applyTo` metadata that can
* match file references in the attached context and then attaches
* such instructions to the context.
*/
private async autoAttachInstructions(attachedContext: ChatRequestVariableSet): Promise<void> {
private async _autoAttachInstructions(attachedContext: ChatRequestVariableSet): Promise<void> {
const existingInstructions = new ResourceSet();
const fileInContext = [];
const variableUris = attachedContext.asArray().map(IChatRequestVariableEntry.toUri).filter(isDefined);
for (const variable of attachedContext.asArray()) {
if (isPromptFileVariableEntry(variable)) {
existingInstructions.add(variable.value);
} else {
const uri = IChatRequestVariableEntry.toUri(variable);
if (uri) {
fileInContext.push(uri);
}
}
}
const automaticInstructions = await this.promptsService.findInstructionFilesFor(variableUris);
const automaticInstructions = await this.promptsService.findInstructionFilesFor(fileInContext, existingInstructions);
const promptVariableEntries = automaticInstructions.map(instruction => toPromptFileVariableEntry(instruction.uri, true, instruction.reason));
// add instructions to the final context list
attachedContext.add(...automaticInstructions.map(instruction => toPromptFileVariableEntry(instruction, true)));
attachedContext.add(...promptVariableEntries);
// add to attached list to make the instructions sticky
this.inputPart.attachmentModel.addPromptFiles(automaticInstructions);
this.inputPart.attachmentModel.addContext(...promptVariableEntries);
}
}
@@ -259,7 +259,7 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli
this._onDidChangeValue.fire();
}
public async toBaseEntries(): Promise<readonly IChatRequestVariableEntry[]> {
public toBaseEntries(): IChatRequestVariableEntry[] {
return [{
kind: 'file',
id: this.id,
@@ -118,7 +118,7 @@ class AttachInstructionsAction extends Action2 {
}
if (skipSelectionDialog && resource) {
widget.attachmentModel.addPromptFiles([resource]);
widget.attachmentModel.addContext(toPromptFileVariableEntry(resource, true));
widget.focusInput();
return;
}
@@ -131,7 +131,7 @@ class AttachInstructionsAction extends Action2 {
const result = await pickers.selectPromptFile({ resource, placeholder, type: PromptsType.instructions });
if (result !== undefined) {
widget.attachmentModel.addPromptFiles([result.promptFile]);
widget.attachmentModel.addContext(toPromptFileVariableEntry(result.promptFile, true));
widget.focusInput();
}
}
@@ -185,6 +185,7 @@ export interface IPromptFileVariableEntry extends IBaseChatRequestVariableEntry
readonly kind: 'promptFile';
readonly value: URI;
readonly isRoot: boolean;
readonly originLabel?: string;
readonly modelDescription: string;
}
@@ -272,7 +273,7 @@ export function isSCMHistoryItemVariableEntry(obj: IChatRequestVariableEntry): o
* @param isRoot If the reference is the root reference in the references tree.
* This object most likely was explicitly attached by the user.
*/
export function toPromptFileVariableEntry(uri: URI, isRoot: boolean): IPromptFileVariableEntry {
export function toPromptFileVariableEntry(uri: URI, isRoot: boolean, originLabel?: string): IPromptFileVariableEntry {
return {
// `id` for all `prompt files` starts with the well-defined part that the copilot extension(or other chatbot) can rely on
id: `vscode.prompt.instructions${isRoot ? '.root' : ''}}__${uri.toString()}`,
@@ -281,29 +282,41 @@ export function toPromptFileVariableEntry(uri: URI, isRoot: boolean): IPromptFil
kind: 'promptFile',
modelDescription: 'Prompt instructions file',
isRoot,
originLabel,
};
}
export class ChatRequestVariableSet {
private _entries = new Map<string, IChatRequestVariableEntry>();
private _ids = new Set<string>();
private _entries: IChatRequestVariableEntry[] = [];
public add(...entry: IChatRequestVariableEntry[]): void {
for (const e of entry) {
if (!this._entries.has(e.id)) {
this._entries.set(e.id, e);
if (!this._ids.has(e.id)) {
this._ids.add(e.id);
this._entries.push(e);
}
}
}
public insertFirst(entry: IChatRequestVariableEntry): void {
if (!this._ids.has(entry.id)) {
this._ids.add(entry.id);
this._entries.unshift(entry);
}
}
public remove(entry: IChatRequestVariableEntry): void {
this._entries.delete(entry.id);
this._ids.delete(entry.id);
this._entries = this._entries.filter(e => e.id !== entry.id);
}
public has(entry: IChatRequestVariableEntry): boolean {
return this._entries.has(entry.id);
return this._ids.has(entry.id);
}
public asArray(): IChatRequestVariableEntry[] {
return Array.from(this._entries.values());
return this._entries.slice(0); // return a copy
}
}
@@ -3,7 +3,6 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { TTree } from '../utils/treeUtils.js';
import { ChatMode } from '../../constants.js';
import { URI } from '../../../../../../base/common/uri.js';
import { Event } from '../../../../../../base/common/event.js';
@@ -15,6 +14,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js
import { PromptsType } from '../promptTypes.js';
import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ITopError } from '../parsers/types.js';
import { ResourceSet } from '../../../../../../base/common/map.js';
/**
* Provides prompt services.
@@ -71,7 +71,7 @@ export interface IMetadata {
/**
* List of metadata for each valid child prompt reference.
*/
readonly children?: readonly TTree<IMetadata | null>[];
readonly children?: readonly IMetadata[];
}
export interface ICustomChatMode {
@@ -173,7 +173,7 @@ export interface IPromptsService extends IDisposable {
/**
* Gets the prompt file for a slash command.
*/
resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise<IMetadata | undefined>;
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<IPromptParserResult | undefined>;
/**
* Returns a prompt command if the command name is valid.
@@ -184,7 +184,7 @@ export interface IPromptsService extends IDisposable {
* Find all instruction files which have a glob pattern in their
* 'applyTo' metadata record that match the provided list of files.
*/
findInstructionFilesFor(fileUris: readonly URI[]): Promise<readonly URI[]>;
findInstructionFilesFor(fileUris: readonly URI[], ignoreInstructions?: ResourceSet): Promise<readonly { uri: URI; reason: string }[]>;
/**
* Event that is triggered when the list of custom chat modes changes.
@@ -196,11 +196,6 @@ export interface IPromptsService extends IDisposable {
*/
getCustomChatModes(token: CancellationToken): Promise<readonly ICustomChatMode[]>;
/**
* Gets the metadata for the given prompt file uri.
*/
getMetadata(promptFileUri: URI): Promise<IMetadata>;
/**
* Get all metadata for entire prompt references tree
* that spans out of each of the provided files.
@@ -216,6 +211,12 @@ export interface IPromptsService extends IDisposable {
* @param uris
*/
parse(uri: URI, token: CancellationToken): Promise<IPromptParserResult>;
/**
* Returns the prompt file type for the given URI.
* @param resource the URI of the resource
*/
getPromptFileType(resource: URI): PromptsType | undefined;
}
export interface IChatPromptSlashCommand {
@@ -3,9 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { flatten } from '../utils/treeUtils.js';
import { localize } from '../../../../../../nls.js';
import { isValidPromptType, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js';
import { getPromptsTypeForLanguageId, isValidPromptType, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js';
import { PromptParser } from '../parsers/promptParser.js';
import { match, splitGlobAware } from '../../../../../../base/common/glob.js';
import { type URI } from '../../../../../../base/common/uri.js';
@@ -27,6 +26,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
import type { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptParserResult, IPromptPath, IPromptsService, TPromptsStorage } from './promptsService.js';
import { getCleanPromptName, PROMPT_FILE_EXTENSION } from '../config/promptFileLocations.js';
import { ILanguageService } from '../../../../../../editor/common/languages/language.js';
/**
* Provides prompt services.
@@ -56,6 +56,7 @@ export class PromptsService extends Disposable implements IPromptsService {
@IModelService private readonly modelService: IModelService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IUserDataProfileService private readonly userDataService: IUserDataProfileService,
@ILanguageService private readonly languageService: ILanguageService,
) {
super();
@@ -102,6 +103,12 @@ export class PromptsService extends Disposable implements IPromptsService {
return this.onDidChangeCustomChatModesEvent;
}
public getPromptFileType(uri: URI): PromptsType | undefined {
const model = this.modelService.getModel(uri);
const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri);
return languageId ? getPromptsTypeForLanguageId(languageId) : undefined;
}
/**
* @throws {Error} if:
@@ -152,12 +159,12 @@ export class PromptsService extends Disposable implements IPromptsService {
return undefined;
}
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise<IMetadata | undefined> {
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<IPromptParserResult | undefined> {
const promptUri = await this.getPromptPath(data);
if (!promptUri) {
return undefined;
}
return await this.getMetadata(promptUri);
return await this.parse(promptUri, token);
}
private async getPromptPath(data: IChatPromptSlashCommand): Promise<URI | undefined> {
@@ -248,31 +255,34 @@ export class PromptsService extends Disposable implements IPromptsService {
}
public async findInstructionFilesFor(files: readonly URI[]): Promise<readonly URI[]> {
public async findInstructionFilesFor(files: readonly URI[], ignoreInstructions?: ResourceSet): Promise<readonly { uri: URI; reason: string }[]> {
const instructionFiles = await this.listPromptFiles(PromptsType.instructions, CancellationToken.None);
if (instructionFiles.length === 0) {
return [];
}
const instructions = await this.getAllMetadata(
instructionFiles.map(file => file.uri),
);
const result: { uri: URI; reason: string }[] = [];
const foundFiles = new ResourceSet();
for (const instruction of instructions.flatMap(flatten)) {
const { metadata, uri } = instruction;
for (const instructionFile of instructionFiles) {
const { metadata, uri } = await this.parse(instructionFile.uri, CancellationToken.None);
if (metadata?.promptType !== PromptsType.instructions) {
continue;
}
if (ignoreInstructions?.has(uri) || foundFiles.has(uri)) {
// the instruction file is already part of the input or has already been processed
continue;
}
const { applyTo } = metadata;
if (applyTo === undefined) {
continue;
}
const patterns = splitGlobAware(applyTo, ',');
const patterMatches = (pattern: string) => {
const patterMatches = (pattern: string): URI | true | false => {
pattern = pattern.trim();
if (pattern.length === 0) {
// if glob pattern is empty, skip it
@@ -293,22 +303,29 @@ export class PromptsService extends Disposable implements IPromptsService {
for (const file of files) {
// if the file is not a valid URI, skip it
if (match(pattern, file.path)) {
return true;
return file;
}
}
return false;
};
if (patterns.some(patterMatches)) {
foundFiles.add(uri);
}
}
return [...foundFiles];
}
public async getMetadata(promptFileUri: URI): Promise<IMetadata> {
const metaDatas = await this.getAllMetadata([promptFileUri]);
return metaDatas[0];
for (const pattern of patterns) {
const matchResult = patterMatches(pattern);
if (matchResult !== false) {
const reason = matchResult === true ?
localize('instruction.file.reason.allFiles', 'Automatically attached as pattern is **') :
localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', applyTo, this.labelService.getUriLabel(matchResult, { relative: true }));
result.push({ uri, reason });
foundFiles.add(uri);
break;
}
}
}
return result;
}
public async getAllMetadata(promptUris: readonly URI[]): Promise<IMetadata[]> {
@@ -1,278 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { assert } from '../../../../../../base/common/assert.js';
/**
* Type for a generic tree node.
*/
export type TTree<TTreenNode> = { children?: readonly TTree<TTreenNode>[] } & TTreenNode;
/**
* Flatter a tree structure into a single flat array.
*/
export function flatten<TTreeNode>(treeRoot: TTree<TTreeNode>): TTreeNode[] {
const result: TTreeNode[] = [];
result.push(treeRoot);
for (const child of treeRoot.children ?? []) {
result.push(...flatten(child));
}
return result;
}
/**
* Traverse a tree structure and execute a callback for each node.
*/
export function forEach<TTreeNode>(callback: (node: TTreeNode) => boolean, treeRoot: TTree<TTreeNode>): ReturnType<typeof callback> {
const shouldStop = callback(treeRoot);
if (shouldStop === true) {
return true;
}
for (const child of treeRoot.children ?? []) {
const childShouldStop = forEach(callback, child);
if (childShouldStop === true) {
return true;
}
}
return false;
}
/**
* Maps nodes of a tree to a new type preserving the original tree structure by invoking
* the provided callback function for each node.
*
* @param callback Function to map each of the nodes in the tree. The callback receives the original
* readonly tree node and a list of its already-mapped readonly children and expected
* to return a new tree node object. If the new object does not have an explicit
* `children` property set (e.g., set to `undefined` or an array), the utility will
* automatically set the `children` property to the `new mapped children` for you,
* otherwise the set `children` property is preserved. Likewise, if the callback
* modifies the `newChildren` array directly, but doesn't explicitly set the `children`
* property on the returned object, the modification to the `newChildren` array are
* preserved in the resulting object.
*
* @param treeRoot The root node of the tree to be mapped.
*
* ### Examples
*
* ```typescript
* const tree = {
* id: '1',
* children: [
* { id: '1.1' },
* { id: '1.2' },
* };
*
* const newTree = map((node, _newChildren) => {
* return {
* name: `name-of-${node.id}`,
* };
* }, tree);
*
* assert.deepStrictEqual(newTree, {
* name: 'name-of-1',
* children: [
* { name: 'name-of-1.1' },
* { name: 'name-of-1.2' },
* });
* ```
*/
export function map<
TTreeNode extends object,
TNewTreeNode extends object
>(
callback: (
originalNode: Readonly<TTree<TTreeNode>>,
newChildren: Readonly<TNewTreeNode>[] | undefined,
) => TTree<TNewTreeNode>,
treeRoot: TTree<TTreeNode>,
): TTree<TNewTreeNode> {
// if the node does not have children, just call the callback
if (treeRoot.children === undefined) {
return callback(treeRoot, undefined);
}
// otherwise process all the children recursively first
const newChildren = treeRoot.children
.map(curry(map, callback));
// then run the callback with the new children
const newNode = callback(treeRoot, newChildren);
// if user explicitly set the children, preserve the value
if ('children' in newNode) {
return newNode;
}
// otherwise if no children is explicitly set,
// use the new children array instead
newNode.children = newChildren;
return newNode;
}
/**
* Type for a generic comparable object - the one that implements
* the `equals` method that allows to compare it with similar objects.
*/
type TComparable<T> = T & { equals: (other: T) => boolean };
/**
* Type for a diff object that represents a difference between
* a pair of objects. See {@link difference} utility and related
* {@link TDifference} for more info.
*/
type TDiff<TObject1, TObject2> = {
/**
* Reference to the first object that was used during
* comparison. Equal to an object of the first tree
* parameter passed to {@link difference}. When set to
* `null`, then the object was missing in the first tree,
* was but present in the second tree.
*/
readonly object1: TObject1;
/**
* Reference to the second object that was used during
* comparison. Equal to an object of the second tree
* parameter passed to {@link difference}. When set to
* `null`, then the object was missing in the second tree,
* was but present in the first tree.
*/
readonly object2: TObject2;
};
/**
* Type for a diff object that represents a difference between
* a pair of objects of the same type.
* See {@link difference} utility for more info.
*
* The type is on-purpose constrained as only one of the object
* references can have the `null` reference but never both of
* them at the same time. This is due to the fact that two `null`
* values would indicate that both objects were missing during
* comparison, which does not make sense in this context.
*/
type TDifference<T> = TDiff<T, T | null> | TDiff<T | null, T>;
/**
* Type for a tree of differences between two trees.
* See {@link difference} utility for more info.
*/
type TDiffTree<T> = TTree<TDifference<T> & {
/**
* Index inside the parent's tree node 'children' array
* reflecting the position of the object pair that was
* compared. Always equal to `0` for a difference at
* the root node level of a tree.
*/
readonly index: number;
}>;
/**
* Utility to find a difference between two provided trees
* of the same type. The result is another tree of difference
* nodes that represent difference between tree node pairs.
*/
export function difference<T extends NonNullable<unknown>>(tree1: TTree<TComparable<T>>, tree2: TTree<TComparable<T>>): TDiffTree<T> | null {
const tree1Children = tree1.children ?? [];
const tree2Children = tree2.children ?? [];
// if there are no children in the both trees left anymore,
// compare the nodes directly themselves and return the result
if (tree1Children.length === 0 && tree2Children.length === 0) {
if (tree1.equals(tree2)) {
return null;
}
return {
index: 0,
object1: tree1,
object2: tree2,
};
}
// with children present, iterate over them to find difference for each pair
const maxChildren = Math.max(tree1Children.length, tree2Children.length);
const children: TDiffTree<T>[] = [];
for (let i = 0; i < maxChildren; i++) {
const child1 = tree1Children[i];
const child2 = tree2Children[i];
// sanity check to ensure that at least one of the children is defined
// as otherwise this case most likely indicates a logic error or a bug
assert(
(child1 !== undefined) || (child2 !== undefined),
'At least one of the children must be defined.',
);
// if one of the children is missing, report it as a difference
if ((child1 === undefined) || (child2 === undefined)) {
children.push({
index: i,
object1: child1 ?? null,
object2: child2 ?? null,
});
continue;
}
const diff = difference(child1, child2);
if (diff === null) {
continue;
}
children.push({
...diff,
index: i,
});
}
// if there some children that are different, report them
if (children.length !== 0) {
return {
index: 0,
object1: tree1,
object2: tree2,
children,
};
}
// there is no children difference, nor differences in the nodes
// themselves, hence return explicit `null` value to indicate that
return null;
}
/**
* Type for a rest parameters of function, excluding
* the first argument.
*/
type TRestParameters<T extends (...args: any[]) => unknown> =
T extends (first: Parameters<T>[0], ...rest: infer R) => unknown ? R : never;
/**
* Type for a curried function.
* See {@link curry} for more info.
*/
type TCurriedFunction<T extends (...args: any[]) => unknown> = ((...args: TRestParameters<T>) => ReturnType<T>);
/**
* Curry a provided function with the first argument.
*/
export function curry<T, K>(
callback: (arg1: T, ...args: any[]) => K,
arg1: T,
): TCurriedFunction<typeof callback> {
return (...args) => {
return callback(arg1, ...args);
};
}
@@ -18,9 +18,6 @@ export class MockPromptsService implements IPromptsService {
getAllMetadata(_files: readonly URI[]): Promise<readonly IMetadata[]> {
throw new Error('Method not implemented.');
}
getMetadata(_file: URI): Promise<IMetadata> {
throw new Error('Method not implemented.');
}
getSyntaxParserFor(_model: ITextModel): TextModelPromptParser & { isDisposed: false } {
throw new Error('Method not implemented.');
}
@@ -33,13 +30,13 @@ export class MockPromptsService implements IPromptsService {
asPromptSlashCommand(command: string): IChatPromptSlashCommand | undefined {
return undefined;
}
resolvePromptSlashCommand(_data: IChatPromptSlashCommand): Promise<IMetadata | undefined> {
resolvePromptSlashCommand(_data: IChatPromptSlashCommand, _token: CancellationToken): Promise<IPromptParserResult | undefined> {
throw new Error('Method not implemented.');
}
findPromptSlashCommands(): Promise<IChatPromptSlashCommand[]> {
throw new Error('Method not implemented.');
}
findInstructionFilesFor(_files: readonly URI[]): Promise<readonly URI[]> {
findInstructionFilesFor(_files: readonly URI[]): Promise<readonly { uri: URI; reason: string }[]> {
throw new Error('Method not implemented.');
}
onDidChangeCustomChatModes: Event<void> = Event.None;
@@ -49,5 +46,8 @@ export class MockPromptsService implements IPromptsService {
parse(uri: URI, token: CancellationToken): Promise<IPromptParserResult> {
throw new Error('Method not implemented.');
}
getPromptFileType(resource: URI): PromptsType | undefined {
throw new Error('Method not implemented.');
}
dispose(): void { }
}
@@ -30,6 +30,7 @@ import { IPromptFileReference } from '../../../../common/promptSyntax/parsers/ty
import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js';
import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js';
import { MockFilesystem } from '../testUtils/mockFilesystem.js';
import { ILabelService } from '../../../../../../../platform/label/common/label.js';
/**
* Helper class to assert the properties of a link.
@@ -126,6 +127,7 @@ suite('PromptsService', () => {
return 'plaintext';
}
});
instaService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path });
const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, fileSystemProvider));
@@ -874,7 +876,7 @@ suite('PromptsService', () => {
]);
assert.deepStrictEqual(
instructions.map(i => i.path),
instructions.map(i => i.uri.path),
[
// local instructions
URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md').path,
@@ -1055,7 +1057,7 @@ suite('PromptsService', () => {
]);
assert.deepStrictEqual(
instructions.map(i => i.path),
instructions.map(i => i.uri.path),
[
// local instructions
URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md').path,
@@ -1,681 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { randomInt } from '../../../../../../../base/common/numbers.js';
import { Range } from '../../../../../../../editor/common/core/range.js';
import { BaseToken } from '../../../../common/promptSyntax/codecs/base/baseToken.js';
import { CompositeToken } from '../../../../common/promptSyntax/codecs/base/compositeToken.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { curry, difference, flatten, forEach, map, TTree } from '../../../../common/promptSyntax/utils/treeUtils.js';
import { ExclamationMark, Space, Tab, VerticalTab, Word } from '../../../../common/promptSyntax/codecs/base/simpleCodec/tokens/tokens.js';
suite('tree utilities', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('flatten', () => {
const tree = {
id: '1',
children: [
{
id: '1.1',
},
{
id: '1.2',
children: [
{
id: '1.2.1',
children: [
{
id: '1.2.1.1',
},
{
id: '1.2.1.2',
},
{
id: '1.2.1.3',
}
],
},
{
id: '1.2.2',
},
]
},
],
};
assert.deepStrictEqual(flatten(tree), [
tree,
tree.children[0],
tree.children[1],
tree.children[1].children![0],
tree.children[1].children![0].children![0],
tree.children[1].children![0].children![1],
tree.children[1].children![0].children![2],
tree.children[1].children![1],
]);
assert.deepStrictEqual(flatten({}), [{}]);
});
suite('forEach', () => {
test('iterates though all nodes', () => {
const tree = {
id: '1',
children: [
{
id: '1.1',
},
{
id: '1.2',
children: [
{
id: '1.2.1',
children: [
{
id: '1.2.1.1',
},
{
id: '1.2.1.2',
},
{
id: '1.2.1.3',
}
],
},
{
id: '1.2.2',
},
]
},
],
};
const treeCopy = JSON.parse(JSON.stringify(tree));
const seenIds: string[] = [];
forEach((node) => {
seenIds.push(node.id);
return false;
}, tree);
assert.deepStrictEqual(seenIds, [
'1',
'1.1',
'1.2',
'1.2.1',
'1.2.1.1',
'1.2.1.2',
'1.2.1.3',
'1.2.2',
]);
assert.deepStrictEqual(
treeCopy,
tree,
'forEach should not modify the tree',
);
});
test('can be stopped prematurely', () => {
const tree = {
id: '1',
children: [
{
id: '1.1',
},
{
id: '1.2',
children: [
{
id: '1.2.1',
children: [
{
id: '1.2.1.1',
},
{
id: '1.2.1.2',
},
{
id: '1.2.1.3',
children: [
{
id: '1.2.1.3.1',
},
],
}
],
},
{
id: '1.2.2',
},
]
},
],
};
const treeCopy = JSON.parse(JSON.stringify(tree));
const seenIds: string[] = [];
forEach((node) => {
seenIds.push(node.id);
if (node.id === '1.2.1') {
return true; // stop traversing
}
return false;
}, tree);
assert.deepStrictEqual(seenIds, [
'1',
'1.1',
'1.2',
'1.2.1',
]);
assert.deepStrictEqual(
treeCopy,
tree,
'forEach should not modify the tree',
);
});
});
suite('map', () => {
test('maps a tree', () => {
interface ITree {
id: string;
children?: ITree[];
}
const tree: ITree = {
id: '1',
children: [
{
id: '1.1',
},
{
id: '1.2',
children: [
{
id: '1.2.1',
children: [
{
id: '1.2.1.1',
},
{
id: '1.2.1.2',
},
{
id: '1.2.1.3',
}
],
},
{
id: '1.2.2',
},
]
},
],
};
const treeCopy = JSON.parse(JSON.stringify(tree));
const newRootNode = {
newId: '__1__',
};
const newChildNode = {
newId: '__1.2.1.3__',
};
const newTree = map((node) => {
if (node.id === '1') {
return newRootNode;
}
if (node.id === '1.2.1.3') {
return newChildNode;
}
return {
newId: `__${node.id}__`,
};
}, tree);
assert.deepStrictEqual(newTree, {
newId: '__1__',
children: [
{
newId: '__1.1__',
},
{
newId: '__1.2__',
children: [
{
newId: '__1.2.1__',
children: [
{
newId: '__1.2.1.1__',
},
{
newId: '__1.2.1.2__',
},
{
newId: '__1.2.1.3__',
},
],
},
{
newId: '__1.2.2__',
},
]
},
],
});
assert(
newRootNode === newTree,
'Map should not replace return node reference (root node).',
);
assert(
newChildNode === newTree.children![1].children![0].children![2],
'Map should not replace return node reference (child node).',
);
assert.deepStrictEqual(
treeCopy,
tree,
'forEach should not modify the tree',
);
});
test('callback can control resulting children', () => {
interface ITree {
id: string;
children?: ITree[];
}
const tree: ITree = {
id: '1',
children: [
{ id: '1.1' },
{
id: '1.2',
children: [
{
id: '1.2.1',
children: [
{ id: '1.2.1.1' },
{ id: '1.2.1.2' },
{
id: '1.2.1.3',
children: [
{
id: '1.2.1.3.1',
},
{
id: '1.2.1.3.2',
},
],
}
],
},
{
id: '1.2.2',
children: [
{ id: '1.2.2.1' },
{ id: '1.2.2.2' },
{ id: '1.2.2.3' },
],
},
{
id: '1.2.3',
children: [
{ id: '1.2.3.1' },
{ id: '1.2.3.2' },
{ id: '1.2.3.3' },
{ id: '1.2.3.4' },
],
},
]
},
],
};
const treeCopy = JSON.parse(JSON.stringify(tree));
const newNodeWithoutChildren = {
newId: '__1.2.1.3__',
children: undefined,
};
const newTree = map((node, newChildren) => {
// validates that explicitly setting `children` to
// `undefined` will be preserved on the resulting new node
if (node.id === '1.2.1.3') {
return newNodeWithoutChildren;
}
// validates that setting `children` to a new array
// will be preserved on the resulting new node
if (node.id === '1.2.2') {
assert.deepStrictEqual(
newChildren,
[
{ newId: '__1.2.2.1__' },
{ newId: '__1.2.2.2__' },
{ newId: '__1.2.2.3__' },
],
`Node '${node.id}' must have correct new children.`,
);
return {
newId: `__${node.id}__`,
children: [newChildren[2]],
};
}
// validates that modifying `newChildren` directly
// will be preserved on the resulting new node
if (node.id === '1.2.3') {
assert.deepStrictEqual(
newChildren,
[
{ newId: '__1.2.3.1__' },
{ newId: '__1.2.3.2__' },
{ newId: '__1.2.3.3__' },
{ newId: '__1.2.3.4__' },
],
`Node '${node.id}' must have correct new children.`,
);
newChildren.length = 2;
return {
newId: `__${node.id}__`,
};
}
// convert to a new node in all other cases
return {
newId: `__${node.id}__`,
};
}, tree);
assert.deepStrictEqual(newTree, {
newId: '__1__',
children: [
{ newId: '__1.1__' },
{
newId: '__1.2__',
children: [
{
newId: '__1.2.1__',
children: [
{ newId: '__1.2.1.1__' },
{ newId: '__1.2.1.2__' },
{
newId: '__1.2.1.3__',
children: undefined,
},
],
},
{
newId: '__1.2.2__',
children: [
{ newId: '__1.2.2.3__' },
],
},
{
newId: '__1.2.3__',
children: [
{ newId: '__1.2.3.1__' },
{ newId: '__1.2.3.2__' },
],
},
]
},
],
});
assert(
newNodeWithoutChildren === newTree.children![1].children![0].children![2],
'Map should not replace return node reference (node without children).',
);
assert.deepStrictEqual(
treeCopy,
tree,
'forEach should not modify the tree',
);
});
});
test('curry', () => {
const originalFunction = (a: number, b: number, c: number) => {
return a + b + c;
};
const firstArgument = randomInt(100, -100);
const curriedFunction = curry(originalFunction, firstArgument);
let iterations = 10;
while (iterations-- > 0) {
const secondArgument = randomInt(100, -100);
const thirdArgument = randomInt(100, -100);
assert.strictEqual(
curriedFunction(secondArgument, thirdArgument),
originalFunction(firstArgument, secondArgument, thirdArgument),
'Curried and original functions must yield the same result.',
);
// a sanity check to ensure we don't compare ambiguous infinities
assert(
isFinite(originalFunction(firstArgument, secondArgument, thirdArgument)),
'Function results must be finite.',
);
}
});
suite('difference', () => {
class TestCompositeToken extends CompositeToken<TTree<BaseToken[]>> {
public override toString(): string {
return `CompositeToken:\n${BaseToken.render(this.children, '\n')})`;
}
}
test('tree roots differ (no children)', () => {
const tree1 = new Word(new Range(1, 1, 1, 1 + 5), 'hello');
const tree2 = new Word(new Range(1, 1, 1, 1 + 5), 'halou');
assert.deepStrictEqual(
difference(tree1, tree2),
{
index: 0,
object1: tree1,
object2: tree2,
},
'Unexpected difference between token trees.',
);
});
test('returns tree difference (single children level)', () => {
const tree1 = asTreeNode<TTree<BaseToken>>(
new Word(new Range(1, 1, 1, 1 + 5), 'hello'),
[
new Space(new Range(1, 6, 1, 7)),
new Word(new Range(1, 7, 1, 7 + 5), 'world'),
],
);
const tree2 = asTreeNode<TTree<BaseToken>>(
new Word(new Range(1, 1, 1, 1 + 5), 'hello'),
[
new Space(new Range(1, 6, 1, 7)),
new Word(new Range(1, 7, 1, 7 + 6), 'world!'),
],
);
assert.deepStrictEqual(
difference(tree1, tree2),
{
index: 0,
object1: tree1,
object2: tree2,
children: [
{
index: 1,
object1: new Word(
new Range(1, 7, 1, 7 + 5),
'world',
),
object2: new Word(
new Range(1, 7, 1, 7 + 6),
'world!',
),
}
],
},
'Unexpected difference between token trees.',
);
});
test('returns tree difference (multiple children levels)', () => {
const compositeToken1 = new TestCompositeToken([
new VerticalTab(new Range(1, 13, 1, 14)),
new Space(new Range(1, 14, 1, 15)),
new Word(new Range(1, 15, 1, 15 + 5), 'again'),
new ExclamationMark(new Range(1, 20, 1, 21)),
]);
const tree1: TTree<BaseToken> = asTreeNode<TTree<BaseToken>>(
new Word(new Range(1, 1, 1, 1 + 5), 'hello'),
[
new Space(new Range(1, 6, 1, 7)),
new Word(new Range(1, 7, 1, 7 + 5), 'world'),
compositeToken1,
],
);
const compositeToken2 = new TestCompositeToken([
new VerticalTab(new Range(1, 13, 1, 14)),
new Space(new Range(1, 14, 1, 15)),
new Word(new Range(1, 15, 1, 15 + 5), 'again'),
new Tab(new Range(1, 20, 1, 21)),
new ExclamationMark(new Range(1, 21, 1, 22)),
]);
const tree2: TTree<BaseToken> = asTreeNode<TTree<BaseToken>>(
new Word(new Range(1, 1, 1, 1 + 5), 'hello'),
[
new Space(new Range(1, 6, 1, 7)),
new Word(new Range(1, 7, 1, 7 + 5), 'world'),
compositeToken2,
],
);
assert.deepStrictEqual(
difference(tree1, tree2),
{
index: 0,
object1: tree1,
object2: tree2,
children: [
{
index: 2,
object1: compositeToken1,
object2: compositeToken2,
children: [
{
index: 3,
object1: compositeToken1.children[3],
object2: compositeToken2.children[3],
},
{
index: 4,
object1: null,
object2: compositeToken2.children[4],
},
],
}
],
},
'Unexpected difference between token trees.',
);
});
test('returns null for equal trees', () => {
const tree1 = new TestCompositeToken([
asTreeNode(new Word(
new Range(1, 1, 1, 1 + 5),
'hello',
), []),
asTreeNode(new Space(new Range(1, 6, 1, 7)), []),
asTreeNode(new Word(
new Range(1, 7, 1, 7 + 6),
'world!',
), []),
]);
const tree2 = new TestCompositeToken([
asTreeNode(new Word(
new Range(1, 1, 1, 1 + 5),
'hello',
), []),
asTreeNode(new Space(new Range(1, 6, 1, 7)), []),
asTreeNode(new Word(
new Range(1, 7, 1, 7 + 6),
'world!',
), []),
]);
assert.strictEqual(
difference(tree1, tree2),
null,
'Unexpected difference between token trees.',
);
assert.strictEqual(
difference(tree1, tree1),
null,
'Must be a null difference when compared with itself.',
);
});
});
});
/**
* Add provided 'children' list to a given object hence
* allowing the object to be used as a general tree node.
*/
function asTreeNode<T extends object>(
item: T,
children: readonly TTree<T>[],
): TTree<T> {
return new Proxy(item, {
get(target, prop, _receiver) {
if (prop === 'children') {
return children;
}
// tokens equality uses the 'constructor' property for
// comparison, hence we need to return the original one
if (prop === 'constructor') {
return target.constructor;
}
const result = Reflect.get(target, prop);
if (typeof result === 'function') {
return result.bind(target);
}
return result;
},
});
}
@@ -202,7 +202,7 @@ suite('InlineChatController', function () {
[ITextModelService, new SyncDescriptor(TextModelResolverService)],
[ILanguageModelToolsService, new SyncDescriptor(MockLanguageModelToolsService)],
[IPromptsService, new class extends mock<IPromptsService>() {
override async findInstructionFilesFor(_file: readonly URI[]): Promise<readonly URI[]> {
override async findInstructionFilesFor(_file: readonly URI[]): Promise<readonly { uri: URI; reason: string }[]> {
return [];
}
}],