mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-11 04:17:01 -05:00
nes: track originating model patch for streamed edits (#322573)
PR #322438 allowed splitting one model-sent diff-patch into multiple LineReplacements, breaking the invariant that the Nth shown edit corresponded to the Nth model patch (progressive ghost-text reveal already broke it too). As a result, telemetry could no longer attribute a served edit back to the model patch it came from. Stamp a 0-based `patchIndex` on each Patch in extractEdits, thread it through the streamed-edit -> cache -> telemetry pipeline, and emit a new `sourcePatchIndex` telemetry measurement. All fragments produced from the same patch (diff splitting or ghost-text early + continuation) share the index. The field is optional since the edit-window and INSERT response formats have no patch structure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
754537e133
commit
0035fb2e7e
@@ -33,6 +33,18 @@ export interface CachedEditOpts {
|
||||
* the cached entry is not served.
|
||||
*/
|
||||
cursorOffset?: number;
|
||||
/**
|
||||
* Zero-based index of the model-emitted patch this edit originated from
|
||||
* (diff-patch response format). `undefined` for formats without an explicit
|
||||
* patch structure. @see StreamedEdit.patchIndex
|
||||
*/
|
||||
patchIndex?: number;
|
||||
/**
|
||||
* Patch indices for the bundled `nextEdits`, aligned by position. Stored on the
|
||||
* first cached entry so that edits served later via rebase (addressed by
|
||||
* `rebasedEditIndex` into the bundle) can be attributed to the right model patch.
|
||||
*/
|
||||
patchIndices?: readonly (number | undefined)[];
|
||||
}
|
||||
|
||||
export interface CachedEdit {
|
||||
@@ -56,6 +68,17 @@ export interface CachedEdit {
|
||||
* When caching multiple edits, this is the order in which they were applied.
|
||||
*/
|
||||
subsequentN?: number;
|
||||
/**
|
||||
* Zero-based index of the model-emitted patch this edit originated from
|
||||
* (diff-patch response format). @see StreamedEdit.patchIndex
|
||||
*/
|
||||
patchIndex?: number;
|
||||
/**
|
||||
* Patch indices for the bundled `edits`, aligned by position. Present on the
|
||||
* first cached entry (the one carrying the `edits` bundle) so rebased subsequent
|
||||
* edits, addressed by `rebasedEditIndex`, can be attributed to the right patch.
|
||||
*/
|
||||
patchIndices?: readonly (number | undefined)[];
|
||||
source: NextEditFetchRequest;
|
||||
cacheTime: number;
|
||||
/**
|
||||
@@ -236,7 +259,7 @@ class DocumentEditCache {
|
||||
|
||||
public setKthNextEdit(documentContents: StringText, editWindow: OffsetRange | undefined, nextEdit: StringReplacement, nextEdits: StringReplacement[] | undefined, userEditSince: StringEdit | undefined, subsequentN: number, source: NextEditFetchRequest, opts: CachedEditOpts): CachedEdit {
|
||||
const key = this._getKey(documentContents.value);
|
||||
const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset };
|
||||
const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, patchIndex: opts.patchIndex, patchIndices: opts.patchIndices, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset };
|
||||
if (userEditSince) {
|
||||
if (!checkEditConsistency(cachedEdit.documentBeforeEdit.value, userEditSince, this._doc.value.get().value, this._logger.createSubLogger('setKthNextEdit'))) {
|
||||
cachedEdit.userEditSince = undefined;
|
||||
|
||||
@@ -97,6 +97,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt
|
||||
docContents: StringText;
|
||||
editsSoFar: StringEdit;
|
||||
nextEdits: StringReplacement[];
|
||||
patchIndices: (number | undefined)[];
|
||||
docId: DocumentId;
|
||||
}> {
|
||||
const statePerDoc = new CachedFunction((id: DocumentId) => {
|
||||
@@ -111,6 +112,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt
|
||||
docContents: baseDocState,
|
||||
editsSoFar: StringEdit.empty,
|
||||
nextEdits: [] as StringReplacement[],
|
||||
patchIndices: [] as (number | undefined)[],
|
||||
docId: id,
|
||||
};
|
||||
}
|
||||
@@ -122,6 +124,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt
|
||||
docContents: doc.documentAfterEdits,
|
||||
editsSoFar: StringEdit.empty,
|
||||
nextEdits: [] as StringReplacement[],
|
||||
patchIndices: [] as (number | undefined)[],
|
||||
docId: id,
|
||||
};
|
||||
});
|
||||
@@ -130,6 +133,23 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt
|
||||
return statePerDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the originating model-patch index for a served edit. In the rebase path
|
||||
* the served edit is addressed by `rebasedEditIndex` into the entry's bundled
|
||||
* `patchIndices`; otherwise the entry carries its own `patchIndex`.
|
||||
*
|
||||
* Invariant: `rebasedEditIndex` is only ever set for entry 0 (the sole entry given a
|
||||
* `patchIndices` bundle), so whenever it is defined `patchIndices` is defined too.
|
||||
* We therefore deliberately do NOT fall back to `patchIndex` in the rebase branch: a
|
||||
* served bundle slot of `undefined` is a genuine "no originating patch" attribution
|
||||
* and must not be masked by entry 0's own patch index.
|
||||
*/
|
||||
function getSourcePatchIndex(cachedEdit: CachedOrRebasedEdit): number | undefined {
|
||||
return cachedEdit.rebasedEditIndex !== undefined
|
||||
? cachedEdit.patchIndices?.[cachedEdit.rebasedEditIndex]
|
||||
: cachedEdit.patchIndex;
|
||||
}
|
||||
|
||||
export interface NESInlineCompletionContext extends vscode.InlineCompletionContext {
|
||||
enforceCacheDelay: boolean;
|
||||
changeHint?: NesChangeHint;
|
||||
@@ -361,6 +381,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
telemetryBuilder.setHeaderRequestId(req.headerRequestId);
|
||||
telemetryBuilder.setIsFromCache();
|
||||
telemetryBuilder.setSubsequentEditOrder(cachedEdit.rebasedEditIndex ?? cachedEdit.subsequentN);
|
||||
// Attribute the served edit to its originating model patch.
|
||||
telemetryBuilder.setSourcePatchIndex(getSourcePatchIndex(cachedEdit));
|
||||
// back-date the recording bookmark of the cached edit to the bookmark of the original request.
|
||||
logContext.recordingBookmark = req.log.recordingBookmark;
|
||||
cacheEntry = cachedEdit.baseCacheEntry ?? cachedEdit;
|
||||
@@ -406,6 +428,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
edit = { actualEdit: suggestedNextEdit, isFromCursorJump: result.val.isFromCursorJump };
|
||||
isFromSpeculativeRequest = result.val.isFromSpeculativeRequest ?? false;
|
||||
cacheEntry = result.val.baseCacheEntry ?? result.val;
|
||||
// Attribute the served (first/fresh) edit to its originating model patch.
|
||||
telemetryBuilder.setSourcePatchIndex(getSourcePatchIndex(result.val));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,7 +773,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
|
||||
let ithEdit = -1;
|
||||
|
||||
const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => {
|
||||
const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId; readonly patchIndex?: number }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => {
|
||||
++ithEdit;
|
||||
const myLogger = logger.createSubLogger('processEdit');
|
||||
myLogger.trace(`processing edit #${ithEdit} (starts at 0)`);
|
||||
@@ -784,6 +808,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
// populate the cache
|
||||
const nextEditReplacement = rebasedEdit.replacements[0];
|
||||
targetDocState.nextEdits.push(nextEditReplacement);
|
||||
targetDocState.patchIndices.push(streamedEdit.patchIndex);
|
||||
cachedEdit = this._nextEditCache.setKthNextEdit(
|
||||
targetDocState.docId,
|
||||
targetDocState.docContents,
|
||||
@@ -793,7 +818,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
ithEdit === 0 ? targetDocState.nextEdits : undefined,
|
||||
ithEdit === 0 ? nextEditRequest.intermediateUserEdit : undefined,
|
||||
req,
|
||||
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? activeDocSelection?.start : undefined }
|
||||
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? activeDocSelection?.start : undefined, patchIndex: streamedEdit.patchIndex, patchIndices: ithEdit === 0 ? targetDocState.patchIndices : undefined }
|
||||
);
|
||||
myLogger.trace(`populated cache for ${ithEdit}`);
|
||||
}
|
||||
@@ -1333,6 +1358,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
if (rebasedEdit.replacements.length === 1) {
|
||||
const nextEditReplacement = rebasedEdit.replacements[0];
|
||||
targetDocState.nextEdits.push(nextEditReplacement);
|
||||
targetDocState.patchIndices.push(streamedEdit.patchIndex);
|
||||
|
||||
// Populate the cache with the speculative result
|
||||
const cachedEdit = this._nextEditCache.setKthNextEdit(
|
||||
@@ -1344,7 +1370,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
ithEdit === 0 ? targetDocState.nextEdits : undefined,
|
||||
undefined, // no userEditSince for speculative
|
||||
req,
|
||||
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? cursorOffset : undefined }
|
||||
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? cursorOffset : undefined, patchIndex: streamedEdit.patchIndex, patchIndices: ithEdit === 0 ? targetDocState.patchIndices : undefined }
|
||||
);
|
||||
|
||||
if (!nextEditRequest.firstEdit.isSettled && cachedEdit) {
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface ILlmNESTelemetry extends Partial<IStatelessNextEditTelemetry> {
|
||||
readonly isFromCache: boolean;
|
||||
readonly reusedRequest: ReusedRequestKind | undefined;
|
||||
readonly subsequentEditOrder: number | undefined;
|
||||
readonly sourcePatchIndex: number | undefined;
|
||||
readonly activeDocumentOriginalLineCount: number | undefined;
|
||||
readonly activeDocumentEditsCount: number | undefined;
|
||||
readonly activeDocumentLanguageId: string | undefined;
|
||||
@@ -244,6 +245,7 @@ export class LlmNESTelemetryBuilder extends Disposable {
|
||||
isFromCache: this._isFromCache,
|
||||
reusedRequest: this._reusedRequest,
|
||||
subsequentEditOrder: this._subsequentEditOrder,
|
||||
sourcePatchIndex: this._sourcePatchIndex,
|
||||
documentsCount,
|
||||
editsCount,
|
||||
activeDocumentEditsCount,
|
||||
@@ -354,6 +356,12 @@ export class LlmNESTelemetryBuilder extends Disposable {
|
||||
return this;
|
||||
}
|
||||
|
||||
private _sourcePatchIndex: number | undefined;
|
||||
public setSourcePatchIndex(sourcePatchIndex: number | undefined): this {
|
||||
this._sourcePatchIndex = sourcePatchIndex;
|
||||
return this;
|
||||
}
|
||||
|
||||
private _request: StatelessNextEditRequest | undefined;
|
||||
public setRequest(request: StatelessNextEditRequest): this {
|
||||
this._request = request;
|
||||
@@ -1006,6 +1014,7 @@ export class TelemetrySender implements IDisposable {
|
||||
isFromCache,
|
||||
reusedRequest,
|
||||
subsequentEditOrder,
|
||||
sourcePatchIndex,
|
||||
activeDocumentLanguageId,
|
||||
activeDocumentOriginalLineCount,
|
||||
nLinesOfCurrentFileInPrompt,
|
||||
@@ -1107,6 +1116,7 @@ export class TelemetrySender implements IDisposable {
|
||||
"isFromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was provided from cache", "isMeasurement": true },
|
||||
"reusedRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the result was obtained by joining a pending request ('speculative' or 'async'), undefined for fresh requests and cache hits" },
|
||||
"subsequentEditOrder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Order of the subsequent edit", "isMeasurement": true },
|
||||
"sourcePatchIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Zero-based index of the model-emitted patch this served edit originated from (diff-patch format). A single model patch can expand into multiple edits that share this index; undefined for formats without explicit patches.", "isMeasurement": true },
|
||||
"activeDocumentOriginalLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document before shortening", "isMeasurement": true },
|
||||
"activeDocumentNLinesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document included in prompt", "isMeasurement": true },
|
||||
"wasPreviouslyRejected": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was previously rejected", "isMeasurement": true },
|
||||
@@ -1206,6 +1216,7 @@ export class TelemetrySender implements IDisposable {
|
||||
nextEditProviderDuration,
|
||||
isFromCache: this._boolToNum(isFromCache),
|
||||
subsequentEditOrder,
|
||||
sourcePatchIndex,
|
||||
activeDocumentOriginalLineCount,
|
||||
activeDocumentNLinesInPrompt: nLinesOfCurrentFileInPrompt,
|
||||
wasPreviouslyRejected: this._boolToNum(wasPreviouslyRejected),
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('NextEditProvider Caching', () => {
|
||||
afterAll(() => {
|
||||
disposableStore.dispose();
|
||||
});
|
||||
function createStatelessNextEditProvider(): IStatelessNextEditProvider {
|
||||
function createStatelessNextEditProvider(patchIndices?: readonly (number | undefined)[]): IStatelessNextEditProvider {
|
||||
return {
|
||||
ID: 'TestNextEditProvider',
|
||||
provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) {
|
||||
@@ -83,8 +83,11 @@ describe('NextEditProvider Caching', () => {
|
||||
)
|
||||
]
|
||||
);
|
||||
let editIndex = 0;
|
||||
for (const edit of lineEdit.replacements) {
|
||||
yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false }, telemetryBuilder.build(Result.ok(undefined)));
|
||||
const patchIndex = patchIndices ? patchIndices[editIndex] : undefined;
|
||||
editIndex++;
|
||||
yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false, patchIndex }, telemetryBuilder.build(Result.ok(undefined)));
|
||||
}
|
||||
const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined);
|
||||
return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));
|
||||
@@ -331,4 +334,51 @@ describe('NextEditProvider Caching', () => {
|
||||
expect(secondCacheEntry).toBe(firstCacheEntry);
|
||||
expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('attributes each served edit to its originating model patch via sourcePatchIndex', async () => {
|
||||
const obsWorkspace = new MutableObservableWorkspace();
|
||||
const obsGit = new ObservableGit(gitExtensionService);
|
||||
// Edits are served in line order: the z parameter (patch 0), the getDistance
|
||||
// body (also patch 0, i.e. a split of the same model patch per PR #322438),
|
||||
// then the variable declaration (patch 1).
|
||||
const statelessNextEditProvider = createStatelessNextEditProvider([0, 0, 1]);
|
||||
|
||||
const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger);
|
||||
|
||||
const doc = obsWorkspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/test.ts').toString()),
|
||||
initialValue: outdent`
|
||||
class Point {
|
||||
constructor(
|
||||
private readonly x: number,
|
||||
private readonly y: number,
|
||||
) { }
|
||||
getDistance() {
|
||||
return Math.sqrt(this.x ** 2 + this.y ** 2);
|
||||
}
|
||||
}
|
||||
|
||||
const myPoint = new Point(0, 1);`.trimStart()
|
||||
});
|
||||
doc.setSelection([new OffsetRange(1, 1)], undefined);
|
||||
doc.applyEdit(StringEdit.insert(11, '3D'));
|
||||
|
||||
const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };
|
||||
const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);
|
||||
const cancellationToken = CancellationToken.None;
|
||||
|
||||
const servedPatchIndices: (number | undefined)[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tb = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);
|
||||
const result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb.nesBuilder);
|
||||
assert(result.result?.edit, `expected an edit on call ${i + 1}`);
|
||||
servedPatchIndices.push(tb.nesBuilder.build(false).sourcePatchIndex);
|
||||
tb.dispose();
|
||||
doc.applyEdit(result.result.edit.toEdit());
|
||||
}
|
||||
|
||||
// The first (fresh) edit must be attributed too, not just the cached ones;
|
||||
// the split pair shares patch 0 while the final edit comes from patch 1.
|
||||
expect(servedPatchIndices).toEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,23 +32,29 @@ class Patch {
|
||||
*/
|
||||
public readonly filePath: string,
|
||||
public readonly lineNumZeroBased: number,
|
||||
/**
|
||||
* Zero-based index of the model-emitted patch this object represents. Patches
|
||||
* derived from the same model header (e.g. the early + continuation patches of a
|
||||
* progressive ghost-text reveal) share the same `patchIndex`.
|
||||
*/
|
||||
public readonly patchIndex: number,
|
||||
) { }
|
||||
|
||||
public static ofLine(line: string): Patch | null {
|
||||
public static ofLine(line: string, patchIndex: number): Patch | null {
|
||||
const match = line.match(/^(.+):(\d+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, filename, lineNumber] = match;
|
||||
return new Patch(filename, parseInt(lineNumber, 10));
|
||||
return new Patch(filename, parseInt(lineNumber, 10), patchIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pure-insertion patch (no removed lines) at the given line.
|
||||
* Used for the continuation portion of a ghost-text progressive reveal.
|
||||
*/
|
||||
public static insertion(filePath: string, lineNumZeroBased: number): Patch {
|
||||
return new Patch(filePath, lineNumZeroBased);
|
||||
public static insertion(filePath: string, lineNumZeroBased: number, patchIndex: number): Patch {
|
||||
return new Patch(filePath, lineNumZeroBased, patchIndex);
|
||||
}
|
||||
|
||||
addLine(line: string): boolean {
|
||||
@@ -399,6 +405,7 @@ export namespace XtabPatchResponseHandler {
|
||||
isFromCursorJump: false,
|
||||
targetDocument,
|
||||
window,
|
||||
patchIndex: edit.patchIndex,
|
||||
} satisfies StreamedEdit;
|
||||
}
|
||||
}
|
||||
@@ -452,14 +459,29 @@ export namespace XtabPatchResponseHandler {
|
||||
export async function* extractEdits(linesStream: AsyncIterable<string>, cursorLineZeroBased?: number, activeDocRelativePath?: string): AsyncGenerator<Patch> {
|
||||
let currentPatch: Patch | null = null;
|
||||
let isFirstPatch = true;
|
||||
|
||||
// Tracks whether we've already attempted progressive reveal (succeeds or fails only once).
|
||||
let progressiveRevealDone = false;
|
||||
|
||||
// Monotonic 0-based index assigned to each real model patch header. Derived
|
||||
// patches (ghost-text early + continuation) inherit their source's index.
|
||||
let nextPatchIndex = 0;
|
||||
// Parses a header line into a patch, allocating it the next patch index.
|
||||
// Returns null for lines that aren't valid headers, which don't consume an index.
|
||||
const parseNextPatchHeader = (line: string): Patch | null => {
|
||||
const patch = Patch.ofLine(line, nextPatchIndex);
|
||||
if (patch !== null) {
|
||||
nextPatchIndex++;
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
|
||||
for await (const line of linesStream) {
|
||||
if (line.trim() === ResponseTags.NO_EDIT) {
|
||||
break;
|
||||
}
|
||||
if (currentPatch === null) {
|
||||
currentPatch = Patch.ofLine(line);
|
||||
currentPatch = parseNextPatchHeader(line);
|
||||
continue;
|
||||
}
|
||||
if (currentPatch.addLine(line)) {
|
||||
@@ -472,13 +494,16 @@ export namespace XtabPatchResponseHandler {
|
||||
&& currentPatch.removedLines.length >= 1
|
||||
) {
|
||||
if (isGhostTextPatch(currentPatch, cursorLineZeroBased, activeDocRelativePath)) {
|
||||
// Both the early and continuation patches stem from the same
|
||||
// model patch, so they share its index.
|
||||
const sourcePatchIndex = currentPatch.patchIndex;
|
||||
// Yield the cursor-line replacement immediately
|
||||
const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased);
|
||||
const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased, sourcePatchIndex);
|
||||
earlyPatch.removedLines = [...currentPatch.removedLines];
|
||||
earlyPatch.addedLines = [...currentPatch.addedLines];
|
||||
yield earlyPatch;
|
||||
// Replace currentPatch with a continuation pure-insertion patch
|
||||
currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1);
|
||||
currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1, sourcePatchIndex);
|
||||
}
|
||||
progressiveRevealDone = true;
|
||||
}
|
||||
@@ -488,7 +513,7 @@ export namespace XtabPatchResponseHandler {
|
||||
if (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0) {
|
||||
yield currentPatch;
|
||||
}
|
||||
currentPatch = Patch.ofLine(line);
|
||||
currentPatch = parseNextPatchHeader(line);
|
||||
isFirstPatch = false;
|
||||
}
|
||||
if (currentPatch && (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0)) {
|
||||
|
||||
@@ -1189,4 +1189,131 @@ another_file.js:
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchIndex attribution', () => {
|
||||
|
||||
it('extractEdits assigns an incrementing index per model patch header', async () => {
|
||||
const linesStream = AsyncIterUtils.fromArray([
|
||||
'a.ts:1',
|
||||
'-a',
|
||||
'+A',
|
||||
'b.ts:2',
|
||||
'-b',
|
||||
'+B',
|
||||
'c.ts:3',
|
||||
'-c',
|
||||
'+C',
|
||||
]);
|
||||
const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream));
|
||||
expect(patches.map(p => p.patchIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('extractEdits does not advance the index for an invalid header', async () => {
|
||||
const linesStream = AsyncIterUtils.fromArray([
|
||||
'a.ts:1',
|
||||
'-a',
|
||||
'+A',
|
||||
'not a valid header',
|
||||
'b.ts:2',
|
||||
'-b',
|
||||
'+B',
|
||||
]);
|
||||
const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream));
|
||||
// The invalid header is skipped, so the two valid patches remain 0 and 1.
|
||||
expect(patches.map(p => p.patchIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('all fragments of a split patch share the originating patchIndex', async () => {
|
||||
const docId = DocumentId.create('file:///test.ts');
|
||||
const docContent = '0\na\nb\nc\nd\ne\n';
|
||||
const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1));
|
||||
|
||||
async function* makeStream(): AsyncGenerator<string> {
|
||||
yield '/test.ts:1';
|
||||
yield '-a';
|
||||
yield '-b';
|
||||
yield '-c';
|
||||
yield '-d';
|
||||
yield '-e';
|
||||
yield '+a';
|
||||
yield '+B';
|
||||
yield '+c';
|
||||
yield '+D';
|
||||
yield '+e';
|
||||
}
|
||||
|
||||
const { edits } = await consumeHandleResponse(
|
||||
makeStream(),
|
||||
documentBeforeEdits,
|
||||
docId,
|
||||
undefined,
|
||||
undefined,
|
||||
new TestLogService(),
|
||||
DuplicateAdditionsMode.Off,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(edits).toHaveLength(2);
|
||||
expect(edits.map(e => e.patchIndex)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('distinct model patches get distinct patchIndex values', async () => {
|
||||
const docId = DocumentId.create('file:///test.ts');
|
||||
const docContent = '0\na\nb\nc\nd\n';
|
||||
const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1));
|
||||
|
||||
async function* makeStream(): AsyncGenerator<string> {
|
||||
yield '/test.ts:1';
|
||||
yield '-a';
|
||||
yield '+A';
|
||||
yield '/test.ts:3';
|
||||
yield '-c';
|
||||
yield '+C';
|
||||
}
|
||||
|
||||
const { edits } = await consumeHandleResponse(
|
||||
makeStream(),
|
||||
documentBeforeEdits,
|
||||
docId,
|
||||
undefined,
|
||||
undefined,
|
||||
new TestLogService(),
|
||||
);
|
||||
|
||||
expect(edits.map(e => e.patchIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('progressive ghost-text fragments share the patchIndex while a following patch advances it', async () => {
|
||||
const docId = DocumentId.create('file:///test.ts');
|
||||
const docContent = 'function foo() {\n let x = 1;\n}\nbar();\n';
|
||||
// Cursor on line 2 (1-based) → cursorLineOffset = 1
|
||||
const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(2, 5));
|
||||
|
||||
async function* makeStream(): AsyncGenerator<string> {
|
||||
yield '/test.ts:1';
|
||||
yield '- let x = 1;';
|
||||
yield '+ let x = 1;';
|
||||
yield '+ let y = 2;';
|
||||
yield '/test.ts:3';
|
||||
yield '-bar();';
|
||||
yield '+baz();';
|
||||
}
|
||||
|
||||
const { edits } = await consumeHandleResponse(
|
||||
makeStream(),
|
||||
documentBeforeEdits,
|
||||
docId,
|
||||
undefined,
|
||||
undefined,
|
||||
new TestLogService(),
|
||||
DuplicateAdditionsMode.Off,
|
||||
true,
|
||||
);
|
||||
|
||||
// First two edits are the ghost-text early + continuation of patch 0;
|
||||
// the third edit comes from the second model patch.
|
||||
expect(edits.map(e => e.patchIndex)).toEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,14 @@ export type StreamedEdit = {
|
||||
* in either the original location or the jump target location.
|
||||
*/
|
||||
readonly originalWindow?: OffsetRange;
|
||||
/**
|
||||
* Zero-based index of the model-emitted patch this edit originated from, for the
|
||||
* diff-patch response format. A single model patch can expand into several edits
|
||||
* (per-patch diff splitting or progressive ghost-text reveal); all edits produced
|
||||
* from the same patch share the same `patchIndex`. `undefined` for response formats
|
||||
* that have no explicit patch structure (e.g. edit-window, INSERT).
|
||||
*/
|
||||
readonly patchIndex?: number;
|
||||
};
|
||||
|
||||
export type PushEdit = (edit: Result<StreamedEdit, NoNextEditReason>) => void;
|
||||
|
||||
Reference in New Issue
Block a user