diff --git a/src/extension/xtab/common/promptCrafting.ts b/src/extension/xtab/common/promptCrafting.ts index af801a67bf..9f7f38020c 100644 --- a/src/extension/xtab/common/promptCrafting.ts +++ b/src/extension/xtab/common/promptCrafting.ts @@ -129,6 +129,9 @@ function getPostScript(strategy: PromptingStrategy | undefined, currentFilePath: case PromptingStrategy.XtabAggressiveness: postScript = `<|aggressive|>${aggressivenessLevel}<|/aggressive|>`; break; + case PromptingStrategy.PatchBased: + postScript = `Output a modified diff style format with the changes you want. Each change patch must start with \`:\` and then include some non empty "anchor lines" preceded by \`-\` and the new lines meant to replace them preceded by \`+\`. Put your changes in the order that makes the most sense, for example edits inside the code_to_edit region and near the user's <|cursor|> should always be prioritized. Output "" if you don't have a good edit candidate.`; + break; case PromptingStrategy.SimplifiedSystemPrompt: case PromptingStrategy.CopilotNesXtab: case undefined: diff --git a/src/extension/xtab/common/tags.ts b/src/extension/xtab/common/tags.ts index ff2d616882..fc75909dcc 100644 --- a/src/extension/xtab/common/tags.ts +++ b/src/extension/xtab/common/tags.ts @@ -36,6 +36,8 @@ export namespace PromptTags { } export namespace ResponseTags { + export const NO_EDIT = ''; + export const NO_CHANGE = { start: '' }; diff --git a/src/extension/xtab/node/xtabCustomDiffPatchResponseHandler.ts b/src/extension/xtab/node/xtabCustomDiffPatchResponseHandler.ts new file mode 100644 index 0000000000..ea1fffdc66 --- /dev/null +++ b/src/extension/xtab/node/xtabCustomDiffPatchResponseHandler.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NoNextEditReason, PushEdit, StreamedEdit } from '../../../platform/inlineEdits/common/statelessNextEditProvider'; +import { Result } from '../../../util/common/result'; +import { AsyncIterableObject } from '../../../util/vs/base/common/async'; +import { LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit'; +import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange'; +import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; +import { StringText } from '../../../util/vs/editor/common/core/text/abstractText'; +import { ResponseTags } from '../common/tags'; + + +class Patch { + public removedLines: string[] = []; + public addedLines: string[] = []; + + private constructor( + public readonly filename: string, + public readonly lineNumZeroBased: number, + ) { } + + public static ofLine(line: string): Patch | null { + const match = line.match(/^(.+):(\d+)$/); + if (!match) { + return null; + } + const [, filename, lineNumber] = match; + return new Patch(filename, parseInt(lineNumber, 10)); + } + + addLine(line: string) { + const contentLine = line.slice(1); + if (line.startsWith('-')) { + this.removedLines.push(contentLine); + return true; + } else if (line.startsWith('+')) { + this.addedLines.push(contentLine); + return true; + } else { + return false; + } + } + + public toString(): string { + return [ + `${this.filename}:${this.lineNumZeroBased}`, + ...this.removedLines.map(l => `-${l}`), + ...this.addedLines.map(l => `+${l}`), + ].join('\n'); + } +} + + +export class XtabCustomDiffPatchResponseHandler { + + public static async handleResponse( + pushEdit: PushEdit, + linesStream: AsyncIterableObject, + documentBeforeEdits: StringText, + window: OffsetRange | undefined, + ): Promise { + let editCount = 0; + for await (const edit of XtabCustomDiffPatchResponseHandler.extractEdits(linesStream)) { + editCount++; + pushEdit(Result.ok({ + edit: XtabCustomDiffPatchResponseHandler.resolveEdit(edit), + window, + // targetDocument, // TODO@ulugbekna: implement target document resolution + } satisfies StreamedEdit)); + } + if (editCount === 0) { + pushEdit(Result.error(new NoNextEditReason.NoSuggestions(documentBeforeEdits, window, undefined))); + } + } + + private static resolveEdit(patch: Patch): LineReplacement { + return new LineReplacement(new LineRange(patch.lineNumZeroBased + 1, patch.lineNumZeroBased + 1 + patch.removedLines.length), patch.addedLines); + } + + public static async *extractEdits(linesStream: AsyncIterableObject): AsyncGenerator { + let currentPatch: Patch | null = null; + for await (const line of linesStream) { + // if no current patch, try to parse a new one + if (line.trim() === ResponseTags.NO_EDIT) { + break; + } + if (currentPatch === null) { + currentPatch = Patch.ofLine(line); + continue; + } + // try to add line to current patch + if (currentPatch.addLine(line)) { + continue; + } else { // line does not belong to current patch, yield current and start new + if (currentPatch) { + yield currentPatch; + } + currentPatch = Patch.ofLine(line); + } + } + if (currentPatch) { + yield currentPatch; + } + } +} diff --git a/src/extension/xtab/node/xtabProvider.ts b/src/extension/xtab/node/xtabProvider.ts index d13bfc4454..ea8f043ee6 100644 --- a/src/extension/xtab/node/xtabProvider.ts +++ b/src/extension/xtab/node/xtabProvider.ts @@ -50,10 +50,11 @@ import { getOrDeduceSelectionFromLastEdit } from '../../inlineEdits/common/nearb import { UserInteractionMonitor } from '../../inlineEdits/common/userInteractionMonitor'; import { IgnoreImportChangesAspect } from '../../inlineEdits/node/importFiltering'; import { LintErrors } from '../common/lintErrors'; -import { constructTaggedFile, countTokensForLines, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces } from '../common/promptCrafting'; +import { constructTaggedFile, countTokensForLines, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces, toUniquePath } from '../common/promptCrafting'; import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifiedModelSystemPrompt, xtab275SystemPrompt } from '../common/systemMessages'; import { PromptTags, ResponseTags } from '../common/tags'; import { CurrentDocument } from '../common/xtabCurrentDocument'; +import { XtabCustomDiffPatchResponseHandler } from './xtabCustomDiffPatchResponseHandler'; import { XtabEndpoint } from './xtabEndpoint'; import { XtabNextCursorPredictor } from './xtabNextCursorPredictor'; import { charCount, constructMessages, linesWithBackticksRemoved, toLines } from './xtabUtils'; @@ -271,7 +272,12 @@ export class XtabProvider implements IStatelessNextEditProvider { areaAroundEditWindowLinesRange, promptOptions, XtabProvider.computeTokens, - { includeLineNumbers: { areaAroundCodeToEdit: false, currentFileContent: promptOptions.promptingStrategy === PromptingStrategy.XtabAggressiveness } } + { + includeLineNumbers: { + areaAroundCodeToEdit: false, + currentFileContent: promptOptions.promptingStrategy === PromptingStrategy.XtabAggressiveness || promptOptions.promptingStrategy === PromptingStrategy.PatchBased, + } + } ); if (taggedCurrentFileContentResult.isError()) { @@ -320,7 +326,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const responseFormat = xtabPromptOptions.ResponseFormat.fromPromptingStrategy(promptOptions.promptingStrategy); - const prediction = this.getPredictedOutput(editWindowLines, responseFormat); + const prediction = this.getPredictedOutput(activeDocument, editWindowLines, responseFormat); const messages = constructMessages({ systemMsg: this.pickSystemPrompt(promptOptions.promptingStrategy), @@ -622,6 +628,13 @@ export class XtabProvider implements IStatelessNextEditProvider { if (opts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowOnly) { cleanedLinesStream = linesStream; + } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch) { + return XtabCustomDiffPatchResponseHandler.handleResponse( + pushEdit, + linesStream, + request.documentBeforeEdits, + editWindow, + ); } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) { const linesIter = linesStream[Symbol.asyncIterator](); const firstLine = await linesIter.next(); @@ -1049,6 +1062,7 @@ export class XtabProvider implements IStatelessNextEditProvider { case xtabPromptOptions.PromptingStrategy.Codexv21NesUnified: case xtabPromptOptions.PromptingStrategy.SimplifiedSystemPrompt: return simplifiedPrompt; + case xtabPromptOptions.PromptingStrategy.PatchBased: case xtabPromptOptions.PromptingStrategy.Xtab275: case xtabPromptOptions.PromptingStrategy.XtabAggressiveness: return xtab275SystemPrompt; @@ -1086,22 +1100,26 @@ export class XtabProvider implements IStatelessNextEditProvider { return createProxyXtabEndpoint(this.instaService, configuredModelName); } - private getPredictedOutput(editWindowLines: string[], responseFormat: xtabPromptOptions.ResponseFormat): Prediction | undefined { + private getPredictedOutput(doc: StatelessNextEditDocument, editWindowLines: string[], responseFormat: xtabPromptOptions.ResponseFormat): Prediction | undefined { return this.configService.getConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderUsePrediction) ? { type: 'content', - content: XtabProvider.getPredictionContents(editWindowLines, responseFormat) + content: this.getPredictionContents(doc, editWindowLines, responseFormat) } : undefined; } - private static getPredictionContents(editWindowLines: readonly string[], responseFormat: xtabPromptOptions.ResponseFormat): string { + private getPredictionContents(doc: StatelessNextEditDocument, editWindowLines: readonly string[], responseFormat: xtabPromptOptions.ResponseFormat): string { if (responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) { return ['', ...editWindowLines, ''].join('\n'); } else if (responseFormat === xtabPromptOptions.ResponseFormat.EditWindowOnly) { return editWindowLines.join('\n'); } else if (responseFormat === xtabPromptOptions.ResponseFormat.CodeBlock) { return ['```', ...editWindowLines, '```'].join('\n'); + } else if (responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch) { + const workspacePath = doc.workspaceRoot?.path; + const workspaceRelativeDocPath = toUniquePath(doc.id, workspacePath); + return `${workspaceRelativeDocPath}:`; } else { assertNever(responseFormat); } diff --git a/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts b/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts new file mode 100644 index 0000000000..46e44e7972 --- /dev/null +++ b/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { AsyncIterableObject } from '../../../../util/vs/base/common/async'; +import { XtabCustomDiffPatchResponseHandler } from '../../node/xtabCustomDiffPatchResponseHandler'; + +describe('XtabCustomDiffPatchResponseHandler', () => { + + async function collectPatches(patchText: string): Promise { + const linesStream = AsyncIterableObject.fromArray(patchText.split('\n')); + const patches: string[] = []; + for await (const patch of XtabCustomDiffPatchResponseHandler.extractEdits(linesStream)) { + patches.push(patch.toString()); + } + return patches.map(p => p.toString()).join('\n'); + } + + it('should parse a simple patch correctly', async () => { + const patchText = `file1.txt:10 +-Old line 1 +-Old line 2 ++New line 1 ++New line 2`; + const patches = await collectPatches(patchText); + expect(patches).toEqual(patchText); + }); + + it('should parse a simple patch correctly', async () => { + const patchText = `/absolutePath/to/my_file.ts:1 +-Old line 1 ++New line 1 ++New line 2 +relative/path/to/another_file.js:42 +-Removed line ++Added line`; + const patches = await collectPatches(patchText); + expect(patches).toEqual(patchText); + }); + + it('discard a patch if no valid header', async () => { + const patchText = `myFile.ts: ++New line 1 ++New line 2 +another_file.js:32 +-Removed line ++Added line`; + const patches = await collectPatches(patchText); + expect(patches).toMatchInlineSnapshot(` + "another_file.js:32 + -Removed line + +Added line" + `); + }); + + it('discard a patch if no valid header - 2', async () => { + const patchText = `myFile.ts:42 ++New line 1 ++New line 2 +another_file.js: +-Removed line ++Added line`; + const patches = await collectPatches(patchText); + expect(patches).toMatchInlineSnapshot(` + "myFile.ts:42 + +New line 1 + +New line 2" + `); + }); + + it('discard a patch has no removed lines', async () => { + const patchText = `myFile.ts:42 ++New line 1 ++New line 2`; + const patches = await collectPatches(patchText); + expect(patches).toMatchInlineSnapshot(` + "myFile.ts:42 + +New line 1 + +New line 2" + `); + }); + + it('discard a patch has no new lines', async () => { + const patchText = `myFile.ts:42 +-Old line 1 +-Old line 2`; + const patches = await collectPatches(patchText); + expect(patches).toMatchInlineSnapshot(` + "myFile.ts:42 + -Old line 1 + -Old line 2" + `); + }); +}); diff --git a/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index 6fe3e66730..6be8f5a9a9 100644 --- a/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -84,6 +84,7 @@ export enum PromptingStrategy { SimplifiedSystemPrompt = 'simplifiedSystemPrompt', Xtab275 = 'xtab275', XtabAggressiveness = 'xtabAggressiveness', + PatchBased = 'patchBased', } export function isPromptingStrategy(value: string): value is PromptingStrategy { @@ -94,6 +95,7 @@ export enum ResponseFormat { CodeBlock = 'codeBlock', UnifiedWithXml = 'unifiedWithXml', EditWindowOnly = 'editWindowOnly', + CustomDiffPatch = 'customDiffPatch', } export namespace ResponseFormat { @@ -106,6 +108,8 @@ export namespace ResponseFormat { case PromptingStrategy.Xtab275: case PromptingStrategy.XtabAggressiveness: return ResponseFormat.EditWindowOnly; + case PromptingStrategy.PatchBased: + return ResponseFormat.CustomDiffPatch; case PromptingStrategy.SimplifiedSystemPrompt: case PromptingStrategy.CopilotNesXtab: case undefined: @@ -188,4 +192,4 @@ export function parseLintOptionString(optionString: string): LintOptions | undef } catch (e) { throw new Error(`Failed to parse lint options string: ${e}`); } -} \ No newline at end of file +} diff --git a/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/src/platform/inlineEdits/common/statelessNextEditProvider.ts index 70cb8fc292..6c350cd2c7 100644 --- a/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -31,7 +31,13 @@ export const enum ShowNextEditPreference { AroundEdit = 'aroundEdit', } -export type PushEdit = (edit: Result<{ edit: LineReplacement; window?: OffsetRange; targetDocument?: DocumentId }, NoNextEditReason>) => void; +export type StreamedEdit = { + readonly edit: LineReplacement; + readonly window?: OffsetRange; + readonly targetDocument?: DocumentId; +} + +export type PushEdit = (edit: Result) => void; export interface IStatelessNextEditProvider { readonly ID: string;