diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index 3659e0c210f..f4e740cd542 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -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; diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index e13a6facef6..1550eaf6e85 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -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 { + 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 { 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), diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts index 5881855dde1..b69ce8e9f38 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts @@ -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]); + }); }); diff --git a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts index 63120ddec1b..542a6a109f2 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts @@ -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, cursorLineZeroBased?: number, activeDocRelativePath?: string): AsyncGenerator { 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)) { diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index ae8ede7eb17..44b190c86cd 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -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 { + 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 { + 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 { + 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]); + }); + }); }); diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index f43503bab05..b4d3cca449b 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -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) => void;