Skip to content

Commit c014116

Browse files
committed
nes: fix: check edit window when reusing in-flight requests
1 parent 2238cf5 commit c014116

6 files changed

Lines changed: 238 additions & 9 deletions

File tree

src/extension/inlineEdits/node/nesConfigs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
export interface INesConfigs {
77
isAsyncCompletions: boolean;
88
isEagerBackupRequest: boolean;
9+
isCheckEditWindowOnReuse: boolean;
910
}

src/extension/inlineEdits/node/nextEditProvider.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
461461
const nesConfigs: INesConfigs = {
462462
isAsyncCompletions: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService),
463463
isEagerBackupRequest: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsEagerBackupRequest, this._expService),
464+
isCheckEditWindowOnReuse: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsCheckEditWindowOnReuse, this._expService),
464465
};
465466

466467
telemetryBuilder.setNESConfigs({ ...nesConfigs });
@@ -516,15 +517,22 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
516517

517518
logContext.setRecentEdit(historyContext);
518519

520+
const cursorAtInvocationTime = selectionAtInvocationTime.at(0);
521+
const cursorInRequestEditWindow = (request: StatelessNextEditRequest) =>
522+
!nesConfigs.isCheckEditWindowOnReuse || !request.requestEditWindow || !cursorAtInvocationTime || request.requestEditWindow.containsCursor(cursorAtInvocationTime);
523+
519524
// Check if we can reuse the regular pending request
520525
const pendingRequestStillCurrent = documentAtInvocationTime.value === this._pendingStatelessNextEditRequest?.documentBeforeEdits.value;
521-
const existingNextEditRequest = (pendingRequestStillCurrent || nesConfigs.isAsyncCompletions) && !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested
526+
const cursorWithinPendingEditWindow = !pendingRequestStillCurrent || !this._pendingStatelessNextEditRequest || cursorInRequestEditWindow(this._pendingStatelessNextEditRequest);
527+
const existingNextEditRequest = (pendingRequestStillCurrent || nesConfigs.isAsyncCompletions) && cursorWithinPendingEditWindow
528+
&& !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested
522529
&& this._pendingStatelessNextEditRequest || undefined;
523530

524531
// Check if we can reuse the speculative pending request (from when a suggestion was shown)
525532
const speculativeRequestMatches = this._speculativePendingRequest?.docId === curDocId
526533
&& this._speculativePendingRequest?.postEditContent === documentAtInvocationTime.value
527-
&& !this._speculativePendingRequest.request.cancellationTokenSource.token.isCancellationRequested;
534+
&& !this._speculativePendingRequest.request.cancellationTokenSource.token.isCancellationRequested
535+
&& cursorInRequestEditWindow(this._speculativePendingRequest.request);
528536
const speculativeRequest = speculativeRequestMatches ? this._speculativePendingRequest?.request : undefined;
529537

530538
// Prefer speculative request if it matches (it was specifically created for this post-edit state)

src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts

Lines changed: 200 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsEnable
1414
import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext';
1515
import { ObservableGit } from '../../../../platform/inlineEdits/common/observableGit';
1616
import { MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace';
17-
import { EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReason, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';
17+
import { EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReason, RequestEditWindow, RequestEditWindowWithCursorJump, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';
1818
import { NesHistoryContextProvider } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';
1919
import { NesXtabHistoryTracker } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';
2020
import { ILogger, ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';
@@ -72,6 +72,12 @@ class TestStatelessNextEditProvider implements IStatelessNextEditProvider {
7272
public readonly calls: ICallRecord[] = [];
7373
private readonly _callDeferreds: DeferredPromise<void>[] = [];
7474

75+
/**
76+
* When set, each `provideNextEdit` call will assign this to `request.requestEditWindow`
77+
* (mirroring how the real XtabProvider sets the edit window early in its execution).
78+
*/
79+
public editWindow: RequestEditWindow | RequestEditWindowWithCursorJump | undefined;
80+
7581
public enqueueBehavior(behavior: ProviderBehavior): void {
7682
this._behaviors.push(behavior);
7783
}
@@ -100,6 +106,12 @@ class TestStatelessNextEditProvider implements IStatelessNextEditProvider {
100106
throw new Error('Missing provider behavior');
101107
}
102108

109+
if (this.editWindow) {
110+
request.requestEditWindow = this.editWindow;
111+
}
112+
113+
const streamedEditWindow = this.editWindow?.window;
114+
const streamedOriginalWindow = this.editWindow instanceof RequestEditWindowWithCursorJump ? this.editWindow.originalWindow : undefined;
103115
const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId);
104116
const activeDocId = request.getActiveDocument().id;
105117
const cancellationRequested = new DeferredPromise<void>();
@@ -128,24 +140,24 @@ class TestStatelessNextEditProvider implements IStatelessNextEditProvider {
128140
}
129141

130142
if (behavior.kind === 'yieldEditThenWaitThenYieldEditsThenNoSuggestions') {
131-
yield new WithStatelessProviderTelemetry({ edit: behavior.firstEdit, isFromCursorJump: false, targetDocument: activeDocId }, telemetryBuilder.build(Result.ok(undefined)));
143+
yield new WithStatelessProviderTelemetry({ edit: behavior.firstEdit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));
132144
await Promise.race([behavior.continueSignal.p, cancellationRequested.p]);
133145
if (!call.wasCancelled) {
134146
for (const edit of behavior.remainingEdits) {
135-
yield new WithStatelessProviderTelemetry({ edit, isFromCursorJump: false, targetDocument: activeDocId }, telemetryBuilder.build(Result.ok(undefined)));
147+
yield new WithStatelessProviderTelemetry({ edit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));
136148
}
137149
}
138-
const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined);
150+
const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, streamedEditWindow);
139151
return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));
140152
}
141153

142-
yield new WithStatelessProviderTelemetry({ edit: behavior.edit, isFromCursorJump: false, targetDocument: activeDocId }, telemetryBuilder.build(Result.ok(undefined)));
154+
yield new WithStatelessProviderTelemetry({ edit: behavior.edit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));
143155

144156
if (behavior.kind === 'yieldEditThenWait') {
145157
await Promise.race([behavior.continueSignal.p, cancellationRequested.p]);
146158
}
147159

148-
const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined);
160+
const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, streamedEditWindow);
149161
return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));
150162
} finally {
151163
cancellationDisposable.dispose();
@@ -1117,4 +1129,186 @@ describe('NextEditProvider speculative requests', () => {
11171129
await statelessProvider.calls[1].completed.p;
11181130
});
11191131
});
1132+
1133+
describe('edit window cursor check for request reuse', () => {
1134+
beforeEach(async () => {
1135+
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsCheckEditWindowOnReuse, true);
1136+
});
1137+
1138+
it('does not reuse in-flight request when cursor moves outside edit window', async () => {
1139+
const statelessProvider = new TestStatelessNextEditProvider();
1140+
// Edit window covers offsets 0–20 of the document
1141+
statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 20));
1142+
const continueSignal1 = new DeferredPromise<void>();
1143+
const continueSignal2 = new DeferredPromise<void>();
1144+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal: continueSignal1 });
1145+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 3;'), continueSignal: continueSignal2 });
1146+
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
1147+
1148+
const doc = workspace.addDocument({
1149+
id: DocumentId.create(URI.file('/test/ew-outside.ts').toString()),
1150+
initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',
1151+
});
1152+
doc.setSelection([new OffsetRange(0, 0)], undefined);
1153+
1154+
// First request — yields first edit, stream still running in background
1155+
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
1156+
assert(firstSuggestion.result?.edit);
1157+
expect(statelessProvider.calls.length).toBe(1);
1158+
1159+
// Move cursor far outside the edit window (offset 40)
1160+
doc.setSelection([new OffsetRange(40, 40)], undefined);
1161+
1162+
// Second request — should NOT reuse the in-flight request because cursor is outside edit window
1163+
// The first request's stream is still running, but cursor is outside its edit window, so a new request is made
1164+
const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);
1165+
assert(secondSuggestion.result?.edit);
1166+
1167+
// Two separate provider calls were made
1168+
expect(statelessProvider.calls.length).toBe(2);
1169+
1170+
// Clean up
1171+
continueSignal1.complete();
1172+
continueSignal2.complete();
1173+
await statelessProvider.calls[0].completed.p;
1174+
await statelessProvider.calls[1].completed.p;
1175+
});
1176+
1177+
it('reuses in-flight request when cursor stays within edit window', async () => {
1178+
const statelessProvider = new TestStatelessNextEditProvider();
1179+
// Edit window covers offsets 0–50 (whole document)
1180+
statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 50));
1181+
const continueSignal = new DeferredPromise<void>();
1182+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });
1183+
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
1184+
1185+
const doc = workspace.addDocument({
1186+
id: DocumentId.create(URI.file('/test/ew-inside.ts').toString()),
1187+
initialValue: 'const value = 1;\nconsole.log(value);\n',
1188+
});
1189+
doc.setSelection([new OffsetRange(0, 0)], undefined);
1190+
1191+
// First request — yields first edit, stream still running
1192+
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
1193+
assert(firstSuggestion.result?.edit);
1194+
expect(statelessProvider.calls.length).toBe(1);
1195+
1196+
// Move cursor but still within the edit window (offset 10)
1197+
doc.setSelection([new OffsetRange(10, 10)], undefined);
1198+
1199+
// Second request — should reuse the in-flight request
1200+
const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);
1201+
assert(secondSuggestion.result?.edit);
1202+
1203+
// Only one provider call was made (reused)
1204+
expect(statelessProvider.calls.length).toBe(1);
1205+
1206+
// Clean up
1207+
continueSignal.complete();
1208+
await statelessProvider.calls[0].completed.p;
1209+
});
1210+
1211+
it('reuses in-flight request when editWindow is undefined (graceful fallback)', async () => {
1212+
const statelessProvider = new TestStatelessNextEditProvider();
1213+
// No editWindow set — should allow reuse
1214+
const continueSignal = new DeferredPromise<void>();
1215+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });
1216+
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
1217+
1218+
const doc = workspace.addDocument({
1219+
id: DocumentId.create(URI.file('/test/ew-undefined.ts').toString()),
1220+
initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',
1221+
});
1222+
doc.setSelection([new OffsetRange(0, 0)], undefined);
1223+
1224+
// First request — yields first edit, stream still running
1225+
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
1226+
assert(firstSuggestion.result?.edit);
1227+
expect(statelessProvider.calls.length).toBe(1);
1228+
1229+
// Move cursor far away — but editWindow is undefined so reuse is allowed
1230+
doc.setSelection([new OffsetRange(40, 40)], undefined);
1231+
1232+
// Second request — should reuse (no edit window to check)
1233+
const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);
1234+
assert(secondSuggestion.result?.edit);
1235+
1236+
expect(statelessProvider.calls.length).toBe(1);
1237+
1238+
// Clean up
1239+
continueSignal.complete();
1240+
await statelessProvider.calls[0].completed.p;
1241+
});
1242+
1243+
it('does not reuse speculative request when cursor moves outside edit window', async () => {
1244+
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
1245+
1246+
const statelessProvider = new TestStatelessNextEditProvider();
1247+
// Edit window covers offsets 0–20
1248+
statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 20));
1249+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
1250+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });
1251+
// Third behavior for the new request that will be needed since speculative won't be reused
1252+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });
1253+
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
1254+
1255+
const doc = workspace.addDocument({
1256+
id: DocumentId.create(URI.file('/test/ew-spec-outside.ts').toString()),
1257+
initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',
1258+
});
1259+
doc.setSelection([new OffsetRange(0, 0)], undefined);
1260+
1261+
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
1262+
assert(firstSuggestion.result?.edit);
1263+
nextEditProvider.handleShown(firstSuggestion);
1264+
await statelessProvider.waitForCall(2);
1265+
await statelessProvider.calls[1].completed.p;
1266+
1267+
// Accept and apply the edit
1268+
nextEditProvider.handleAcceptance(doc.id, firstSuggestion);
1269+
doc.applyEdit(firstSuggestion.result.edit.toEdit());
1270+
1271+
// Move cursor outside the speculative request's edit window
1272+
doc.setSelection([new OffsetRange(40, 40)], undefined);
1273+
1274+
// This should NOT reuse the speculative request (cursor is outside)
1275+
await getNextEdit(nextEditProvider, doc.id);
1276+
1277+
// Three calls: original, speculative, and a new one (speculative was not reused)
1278+
expect(statelessProvider.calls.length).toBe(3);
1279+
});
1280+
1281+
it('reuses in-flight request when cursor is within originalWindow of cursor jump edit window', async () => {
1282+
const statelessProvider = new TestStatelessNextEditProvider();
1283+
// Cursor jump: new window is at 30–50, original window is at 0–20
1284+
statelessProvider.editWindow = new RequestEditWindowWithCursorJump(new OffsetRange(30, 50), new OffsetRange(0, 20));
1285+
const continueSignal = new DeferredPromise<void>();
1286+
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });
1287+
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
1288+
1289+
const doc = workspace.addDocument({
1290+
id: DocumentId.create(URI.file('/test/ew-cursorjump.ts').toString()),
1291+
initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\nconst extra = 4;\n',
1292+
});
1293+
doc.setSelection([new OffsetRange(0, 0)], undefined);
1294+
1295+
// First request — yields first edit, stream still running
1296+
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
1297+
assert(firstSuggestion.result?.edit);
1298+
expect(statelessProvider.calls.length).toBe(1);
1299+
1300+
// Move cursor to offset 10 — inside originalWindow (0–20) but outside jump target (30–50)
1301+
doc.setSelection([new OffsetRange(10, 10)], undefined);
1302+
1303+
// Second request — should reuse because cursor is in originalWindow
1304+
const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);
1305+
assert(secondSuggestion.result?.edit);
1306+
1307+
expect(statelessProvider.calls.length).toBe(1);
1308+
1309+
// Clean up
1310+
continueSignal.complete();
1311+
await statelessProvider.calls[0].completed.p;
1312+
});
1313+
});
11201314
});

src/extension/xtab/node/xtabProvider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { AggressivenessSetting, isAggressivenessStrategy, LanguageContextLanguag
2222
import { InlineEditRequestLogContext } from '../../../platform/inlineEdits/common/inlineEditLogContext';
2323
import { IInlineEditsModelService } from '../../../platform/inlineEdits/common/inlineEditsModelService';
2424
import { ResponseProcessor } from '../../../platform/inlineEdits/common/responseProcessor';
25-
import { EditStreaming, EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../platform/inlineEdits/common/statelessNextEditProvider';
25+
import { EditStreaming, EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReason, RequestEditWindow, RequestEditWindowWithCursorJump, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../platform/inlineEdits/common/statelessNextEditProvider';
2626
import { editWouldDeleteWhatWasJustInserted, editWouldDeleteWhatWasJustInserted2, IgnoreEmptyLineAndLeadingTrailingWhitespaceChanges, IgnoreWhitespaceOnlyChanges } from '../../../platform/inlineEdits/common/statelessNextEditProviders';
2727
import { ILanguageContextProviderService, ProviderTarget } from '../../../platform/languageContextProvider/common/languageContextProviderService';
2828
import { ILanguageDiagnosticsService } from '../../../platform/languages/common/languageDiagnosticsService';
@@ -270,6 +270,10 @@ export class XtabProvider implements IStatelessNextEditProvider {
270270
const editWindowLastLineLength = currentDocument.transformer.getLineLength(editWindowLinesRange.endExclusive);
271271
const editWindow = currentDocument.transformer.getOffsetRange(new Range(editWindowLinesRange.start + 1, 1, editWindowLinesRange.endExclusive, editWindowLastLineLength + 1));
272272

273+
request.requestEditWindow = originalEditWindow
274+
? new RequestEditWindowWithCursorJump(editWindow, originalEditWindow)
275+
: new RequestEditWindow(editWindow);
276+
273277
const editWindowLines = currentDocument.lines.slice(editWindowLinesRange.start, editWindowLinesRange.endExclusive);
274278

275279
const editWindowTokenLimit = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabEditWindowMaxTokens, this.expService);

0 commit comments

Comments
 (0)