Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/extension/xtab/common/promptCrafting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`<filename>:<line number>\` 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 "<NO_EDIT>" if you don't have a good edit candidate.`;
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt description states that each patch "must...include some non empty 'anchor lines' preceded by -", which implies that patches without removed lines should be invalid. However, the implementation in xtabCustomDiffPatchResponseHandler.ts does not validate that patches have removed lines. Consider adding validation to ensure patches conform to the format described in the prompt, or update the prompt description if patches with only additions are intentionally supported.

Copilot uses AI. Check for mistakes.
break;
case PromptingStrategy.SimplifiedSystemPrompt:
case PromptingStrategy.CopilotNesXtab:
case undefined:
Expand Down
2 changes: 2 additions & 0 deletions src/extension/xtab/common/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export namespace PromptTags {
}

export namespace ResponseTags {
export const NO_EDIT = '<NO_EDIT>';

export const NO_CHANGE = {
start: '<NO_CHANGE>'
};
Expand Down
108 changes: 108 additions & 0 deletions src/extension/xtab/node/xtabCustomDiffPatchResponseHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
documentBeforeEdits: StringText,
window: OffsetRange | undefined,
): Promise<void> {
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<string>): AsyncGenerator<Patch> {
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;
}
}
}
30 changes: 24 additions & 6 deletions src/extension/xtab/node/xtabProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ['<EDIT>', ...editWindowLines, '</EDIT>'].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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
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');
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant call to toString(). The patches array already contains strings (from patch.toString() on line 16), so calling .map(p => p.toString()) again on line 18 is unnecessary. Remove the redundant .map() call and simply use patches.join('\n').

Suggested change
return patches.map(p => p.toString()).join('\n');
return patches.join('\n');

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point?

}

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 () => {
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name is misleading and the behavior may not match the prompt description. The test name says "discard a patch has no removed lines" but the expectation shows the patch is NOT discarded. According to the prompt description in promptCrafting.ts line 133, patches must include "some non empty 'anchor lines' preceded by -", which suggests patches without removed lines should be invalid. Either the test expectations should show an empty result (if patches should be discarded), or the test name should be updated to reflect that such patches are accepted.

Copilot uses AI. Check for mistakes.
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 () => {
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name is misleading. The test name says "discard a patch has no new lines" but the expectation shows the patch is NOT discarded - it's included in the output. Consider renaming to something like 'should parse patch with only removed lines (deletions)'.

Copilot uses AI. Check for mistakes.
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"
`);
});
});
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the NO_EDIT tag scenario. The implementation on line 87 handles the case where the model outputs "<NO_EDIT>", but there's no test verifying this behavior. Consider adding a test case that verifies the handler correctly stops processing and returns no edits when it encounters the NO_EDIT tag.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export enum PromptingStrategy {
SimplifiedSystemPrompt = 'simplifiedSystemPrompt',
Xtab275 = 'xtab275',
XtabAggressiveness = 'xtabAggressiveness',
PatchBased = 'patchBased',
}

export function isPromptingStrategy(value: string): value is PromptingStrategy {
Expand All @@ -94,6 +95,7 @@ export enum ResponseFormat {
CodeBlock = 'codeBlock',
UnifiedWithXml = 'unifiedWithXml',
EditWindowOnly = 'editWindowOnly',
CustomDiffPatch = 'customDiffPatch',
}

export namespace ResponseFormat {
Expand All @@ -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:
Expand Down Expand Up @@ -188,4 +192,4 @@ export function parseLintOptionString(optionString: string): LintOptions | undef
} catch (e) {
throw new Error(`Failed to parse lint options string: ${e}`);
}
}
}
8 changes: 7 additions & 1 deletion src/platform/inlineEdits/common/statelessNextEditProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamedEdit, NoNextEditReason>) => void;

export interface IStatelessNextEditProvider {
readonly ID: string;
Expand Down
Loading