diff --git a/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts b/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts index dd9ae35aca5..b7ab10a1a87 100644 --- a/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts +++ b/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts @@ -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 { @@ -45,7 +57,7 @@ export class EditDataWithIndex implements IEditData { } } -export function tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement[][], 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[][], 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>(content: string, ours: StringEdit, base: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs = {}): StringEdit | undefined { +export function tryRebaseStringEdits>(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>(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>(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>(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; diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index bcaa766ee38..d1f7781fa73 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -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, }; } diff --git a/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts b/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts index bc278d8feea..e71ff6bfc0e 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts @@ -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('});'); diff --git a/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts index 45790da6b1f..81ae8b7b418 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts @@ -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 diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 7c7033a50da..41f8696ca62 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -785,6 +785,7 @@ export namespace ConfigKey { export const InlineEditsRebasedCacheDelay = defineTeamInternalSetting('chat.advanced.inlineEdits.rebasedCacheDelay', ConfigType.ExperimentBased, 0); export const InlineEditsAbsorbSubsequenceTyping = defineTeamInternalSetting('chat.advanced.inlineEdits.absorbSubsequenceTyping', ConfigType.ExperimentBased, false); export const InlineEditsReverseAgreement = defineTeamInternalSetting('chat.advanced.inlineEdits.reverseAgreement', ConfigType.ExperimentBased, false); + export const InlineEditsMaxImperfectAgreementLength = defineTeamInternalSetting('chat.advanced.inlineEdits.maxImperfectAgreementLength', ConfigType.ExperimentBased, 5, vNumber()); export const InlineEditsBackoffDebounceEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.backoffDebounceEnabled', ConfigType.ExperimentBased, true); export const InlineEditsExtraDebounceEndOfLine = defineTeamInternalSetting('chat.advanced.inlineEdits.extraDebounceEndOfLine', ConfigType.ExperimentBased, 2000); export const InlineEditsSpeculativeRequests = defineTeamInternalSetting('chat.advanced.inlineEdits.speculativeRequests', ConfigType.ExperimentBased, SpeculativeRequestsEnablement.Off, SpeculativeRequestsEnablement.VALIDATOR);