nes: make tryRebase conflict limit exp-configurable (#312708)

Add `maxImperfectAgreementLength` to `NesRebaseConfigs` as a required field (no fallback needed at call sites). Wire it up through a new experiment-based `ConfigKey.TeamInternal.InlineEditsMaxImperfectAgreementLength` setting (default 5, key `chat.advanced.inlineEdits.maxImperfectAgreementLength`).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ulugbek Abdullaev
2026-04-27 18:03:18 +05:00
committed by GitHub
parent 8d0b49d0cf
commit e46ea1e654
5 changed files with 50 additions and 38 deletions

View File

@@ -14,6 +14,9 @@ import { ILinesDiffComputerOptions } from '../../../util/vs/editor/common/diff/l
const TROUBLESHOOT_EDIT_CONSISTENCY = false;
export const maxAgreementOffset = 10; // If the user's typing is more than this into the suggestion we consider it a miss.
export const maxImperfectAgreementLength = 5; // If the user's typing is longer than this and the suggestion is not a perfect match we consider it a miss.
export interface NesRebaseConfigs {
/**
* When enabled, if the user's typed text is an editor auto-close pair
@@ -30,6 +33,15 @@ export interface NesRebaseConfigs {
* rebased suggestion.
*/
readonly reverseAgreement?: boolean;
/**
* Maximum length (in characters) of an imperfect agreement that is still
* accepted during a strict rebase. When the base new-text is longer than
* this value and it does not start at the exact predicted offset, the
* rebase is considered a miss. Helper functions such as {@link tryRebase}
* use {@link maxImperfectAgreementLength} when `nesConfigs` is omitted,
* but explicit {@link NesRebaseConfigs} objects must provide this value.
*/
readonly maxImperfectAgreementLength: number;
}
export class EditDataWithIndex implements IEditData<EditDataWithIndex> {
@@ -45,7 +57,7 @@ export class EditDataWithIndex implements IEditData<EditDataWithIndex> {
}
}
export function tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSince: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs = {}): { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] | 'outsideEditWindow' | 'rebaseFailed' | 'error' | 'inconsistentEdits' {
export function tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSince: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] | 'outsideEditWindow' | 'rebaseFailed' | 'error' | 'inconsistentEdits' {
const start = Date.now();
try {
return _tryRebase(originalDocument, editWindow, originalEdits, detailedEdits, userEditSince, currentDocumentContent, currentSelection, resolution, logger, nesConfigs);
@@ -133,7 +145,7 @@ export function checkEditConsistency(original: string, edit: StringEdit, current
return consistent;
}
export function tryRebaseStringEdits<T extends IEditData<T>>(content: string, ours: StringEdit, base: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs = {}): StringEdit | undefined {
export function tryRebaseStringEdits<T extends IEditData<T>>(content: string, ours: StringEdit, base: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): StringEdit | undefined {
return tryRebaseEdits(content, ours.mapData(r => new VoidEditData()), base, resolution, nesConfigs)?.toStringEdit();
}
@@ -237,7 +249,7 @@ function tryRebaseEdits<T extends IEditData<T>>(content: string, ours: Annotated
const j = baseEdit.newText.indexOf(effectiveText, baseNewTextOffset);
const strictRejected = j !== -1 && resolution === 'strict' && (
j - baseNewTextOffset > maxAgreementOffset ||
(j - baseNewTextOffset > 0 && effectiveText.length > maxImperfectAgreementLength)
(j - baseNewTextOffset > 0 && effectiveText.length > nesConfigs.maxImperfectAgreementLength)
);
if (j !== -1 && !strictRejected) {
@@ -312,9 +324,6 @@ function tryRebaseEdits<T extends IEditData<T>>(content: string, ours: Annotated
return AnnotatedStringEdit.create(newEdits);
}
export const maxAgreementOffset = 10; // If the user's typing is more than this into the suggestion we consider it a miss.
export const maxImperfectAgreementLength = 5; // If the user's typing is longer than this and the suggestion is not a perfect match we consider it a miss.
/** Returns true if every character of `typed` appears in `suggestion` in order (subsequence match). */
function isSubsequenceOf(typed: string, suggestion: string): boolean {
let si = 0;
@@ -347,7 +356,7 @@ function agreementIndexOf<T extends IEditData<T>>(content: string, ourE: Annotat
const j = ourE.newText.indexOf(baseE.newText, ourNewTextOffset);
const strictRejected = j !== -1 && resolution === 'strict' && (
j > maxAgreementOffset ||
(j > 0 && baseE.newText.length > maxImperfectAgreementLength)
(j > 0 && baseE.newText.length > nesConfigs.maxImperfectAgreementLength)
);
if (j !== -1 && !strictRejected) {
return j + baseE.newText.length;

View File

@@ -125,9 +125,12 @@ export class NextEditCache extends Disposable {
}
private _getNesRebaseConfigs(): NesRebaseConfigs {
const maxImperfectAgreementLength = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsMaxImperfectAgreementLength, this._expService);
return {
absorbSubsequenceTyping: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAbsorbSubsequenceTyping, this._expService),
reverseAgreement: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsReverseAgreement, this._expService),
maxImperfectAgreementLength: typeof maxImperfectAgreementLength === 'number' ? Math.max(0, maxImperfectAgreementLength) : maxImperfectAgreementLength,
};
}

View File

@@ -114,22 +114,21 @@ export class RebaseFailureInfo implements MarkdownLoggable {
lines.push(`\tconst currentSelection = [${this.currentSelection.map(s => `new OffsetRange(${s.start}, ${s.endExclusive})`).join(', ')}];`);
if (this.nesRebaseConfigs.absorbSubsequenceTyping || this.nesRebaseConfigs.reverseAgreement) {
const configEntries: string[] = [];
if (this.nesRebaseConfigs.absorbSubsequenceTyping) {
configEntries.push(`absorbSubsequenceTyping: ${this.nesRebaseConfigs.absorbSubsequenceTyping}`);
}
if (this.nesRebaseConfigs.reverseAgreement) {
configEntries.push(`reverseAgreement: ${this.nesRebaseConfigs.reverseAgreement}`);
}
lines.push(`\tconst nesConfigs = { ${configEntries.join(', ')} };`);
const configEntries: string[] = [];
if (this.nesRebaseConfigs.absorbSubsequenceTyping) {
configEntries.push(`absorbSubsequenceTyping: ${this.nesRebaseConfigs.absorbSubsequenceTyping}`);
}
if (this.nesRebaseConfigs.reverseAgreement) {
configEntries.push(`reverseAgreement: ${this.nesRebaseConfigs.reverseAgreement}`);
}
configEntries.push(`maxImperfectAgreementLength: ${this.nesRebaseConfigs.maxImperfectAgreementLength}`);
lines.push(`\tconst nesConfigs = { ${configEntries.join(', ')} };`);
lines.push('');
lines.push('\tconst logger = new TestLogService();');
lines.push('\texpect(userEditSince.apply(originalDocument)).toBe(currentDocumentContent);');
const configsArg = (this.nesRebaseConfigs.absorbSubsequenceTyping || this.nesRebaseConfigs.reverseAgreement) ? ', nesConfigs' : '';
const configsArg = ', nesConfigs';
lines.push(`\texpect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger${configsArg})).toMatchInlineSnapshot();`);
lines.push('});');

View File

@@ -279,7 +279,7 @@ int main()
const currentDocumentContent = 'function fib()\n';
const editWindow = new OffsetRange(0, 13);
const currentSelection = [new OffsetRange(13, 13)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
const final = 'function fib(n: number): number {\n if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n';
@@ -313,7 +313,7 @@ int main()
const currentDocumentContent = 'function fib(n: )\n';
const editWindow = new OffsetRange(0, 13);
const currentSelection = [new OffsetRange(16, 16)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -333,7 +333,7 @@ int main()
const currentDocumentContent = 'const x;\n';
const editWindow = new OffsetRange(0, 8);
const currentSelection = [new OffsetRange(8, 8)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -354,7 +354,7 @@ int main()
const currentDocumentContent = 'const x;\n';
const editWindow = new OffsetRange(0, 8);
const currentSelection = [new OffsetRange(8, 8)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// Strict rejects the exact match (offset 25 > maxAgreementOffset) and absorption
@@ -375,7 +375,7 @@ int main()
const currentDocumentContent = 'function fibabc\n';
const editWindow = new OffsetRange(0, 13);
const currentSelection = [new OffsetRange(15, 15)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -395,7 +395,7 @@ int main()
const currentDocumentContent = 'function fib(a\n';
const editWindow = new OffsetRange(0, 13);
const currentSelection = [new OffsetRange(14, 14)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -418,7 +418,7 @@ int main()
const logger = new TestLogService();
// Explicitly disabled
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, { absorbSubsequenceTyping: false })).toBe('rebaseFailed');
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, { absorbSubsequenceTyping: false, maxImperfectAgreementLength })).toBe('rebaseFailed');
// Default (no config)
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger)).toBe('rebaseFailed');
});
@@ -436,7 +436,7 @@ int main()
const currentDocument = userEdit.apply(originalDocument);
expect(currentDocument).toBe('function fib(n\n');
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);
expect(res).toBeDefined();
expect(res!.apply(currentDocument)).toBe(suggestedEdit.apply(originalDocument));
@@ -457,7 +457,7 @@ int main()
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
// With config: still fails because a single "{" is not an auto-close pair
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true })).toBeUndefined();
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();
});
test('absorbSubsequenceTyping: "{}" NOT absorbed when suggestion only has opening brace', () => {
@@ -473,7 +473,7 @@ int main()
const current = userEdit.apply(text);
expect(current).toBe('if (true){}\n');
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true })).toBeUndefined();
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();
});
test('absorbSubsequenceTyping: "{}" absorbed when suggestion has both braces', () => {
@@ -490,7 +490,7 @@ int main()
const final = suggestion.apply(text);
expect(final).toBe('if (true) {\n console.log("yes");\n}\n');
const result = tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true });
const result = tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength });
expect(result).toBeDefined();
expect(result!.apply(current)).toBe(final);
});
@@ -509,7 +509,7 @@ int main()
const currentDocumentContent = 'function fib(n: number) {}\n';
const editWindow = new OffsetRange(0, 25);
const currentSelection = [new OffsetRange(26, 26)];
const nesConfigs = { absorbSubsequenceTyping: true };
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
const logger = new TestLogService();
const final = 'function fib(n: number) {\n if (n <= 1) return 1;\n return n * factorial(n - 1);\n}\n';
@@ -1105,7 +1105,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
]);
const currentDocumentContent = 'class Fibonacci {\n\t\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// Without flag: rebase fails
@@ -1157,7 +1157,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo XYZ'),
]);
const currentDocumentContent = 'class Foo XYZ\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -1180,7 +1180,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 5), 'helloXX{YY'),
]);
const currentDocumentContent = 'helloXX{YY\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// Fails because user's remaining text "YY" doesn't match model's second edit
@@ -1203,7 +1203,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 1), 'a' + pad + '{'),
]);
const currentDocumentContent = 'a' + pad + '{\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
@@ -1224,7 +1224,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 1), 'aX' + longText),
]);
const currentDocumentContent = 'aX' + longText + '\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// offset = 1 > 0, effectiveText.length = longText.length > maxImperfectAgreementLength
@@ -1244,7 +1244,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\tfoo\n}'),
]);
const currentDocumentContent = 'fn {\n\tfoo\n}\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// Without flag: rebase fails
@@ -1270,7 +1270,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
]);
const currentDocumentContent = 'class Fibonacci {\n\t\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
// Expected final: apply both model edits in sequence to original
const expectedFinal = new StringEdit([originalEdits[0]]).apply(originalDocument);
@@ -1308,7 +1308,7 @@ class Point3D {
// Without flag: rebase fails
expect(tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict')).toBeUndefined();
// With flag: model edit fully consumed → empty result
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);
expect(res).toBeDefined();
expect(res!.replacements.length).toBe(0);
@@ -1326,7 +1326,7 @@ class Point3D {
StringReplacement.replace(new OffsetRange(0, 5), 'XYZWV'),
]);
const currentDocumentContent = 'XYZWV\n';
const nesConfigs = { reverseAgreement: true };
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
const logger = new TestLogService();
// The ranges don't match after removeCommonSuffixAndPrefix, so this conflicts

View File

@@ -785,6 +785,7 @@ export namespace ConfigKey {
export const InlineEditsRebasedCacheDelay = defineTeamInternalSetting<number | undefined>('chat.advanced.inlineEdits.rebasedCacheDelay', ConfigType.ExperimentBased, 0);
export const InlineEditsAbsorbSubsequenceTyping = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.absorbSubsequenceTyping', ConfigType.ExperimentBased, false);
export const InlineEditsReverseAgreement = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.reverseAgreement', ConfigType.ExperimentBased, false);
export const InlineEditsMaxImperfectAgreementLength = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.maxImperfectAgreementLength', ConfigType.ExperimentBased, 5, vNumber());
export const InlineEditsBackoffDebounceEnabled = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.backoffDebounceEnabled', ConfigType.ExperimentBased, true);
export const InlineEditsExtraDebounceEndOfLine = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.extraDebounceEndOfLine', ConfigType.ExperimentBased, 2000);
export const InlineEditsSpeculativeRequests = defineTeamInternalSetting<SpeculativeRequestsEnablement>('chat.advanced.inlineEdits.speculativeRequests', ConfigType.ExperimentBased, SpeculativeRequestsEnablement.Off, SpeculativeRequestsEnablement.VALIDATOR);