Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions extensions/copilot/src/extension/inlineEdits/common/editRebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export interface NesRebaseConfigs {
* the typed pair instead of failing.
*/
readonly absorbSubsequenceTyping?: boolean;
/**
* When enabled, allows rebase to succeed when the user typed more text
* than the model predicted at the same position (reverse agreement).
* Model edits consumed by the user's typing are absorbed, and any
* unconsumed portion of subsequent model edits is offered as the
* rebased suggestion.
*/
readonly reverseAgreement?: boolean;
}

export class EditDataWithIndex implements IEditData<EditDataWithIndex> {
Expand Down Expand Up @@ -209,6 +217,75 @@ function tryRebaseEdits<T extends IEditData<T>>(content: string, ours: Annotated
));
ourIdx++;
offset += delta;
} else if (nesConfigs.reverseAgreement && ourEdit.replaceRange.equals(baseEdit.replaceRange)) {
// Reverse agreement: user's edit (base) covers model's edit (ours)
// at the same range. The user typed more than the model predicted.
// Use ourEdit (pre-shift) to avoid false matches from shift alignment.
// Iterate over consecutive our-edits consumed by this base edit.
let baseNewTextOffset = 0;
let previousOurE: AnnotatedStringReplacement<T> | undefined;

while (ourIdx < ours.replacements.length && baseEdit.replaceRange.containsRange(ours.replacements[ourIdx].replaceRange)) {
const curOurE = ours.replacements[ourIdx];

// Account for gap content between previous our-edit end and current our-edit start
const gapStart = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;
const gapText = gapStart < curOurE.replaceRange.start ? content.substring(gapStart, curOurE.replaceRange.start) : '';
const effectiveText = gapText + curOurE.newText;

// Try full consumption: model text found entirely within user text
const j = baseEdit.newText.indexOf(effectiveText, baseNewTextOffset);
const strictRejected = j !== -1 && resolution === 'strict' && (
j - baseNewTextOffset > maxAgreementOffset ||
(j - baseNewTextOffset > 0 && effectiveText.length > maxImperfectAgreementLength)
);

if (j !== -1 && !strictRejected) {
// Full consumption — model edit absorbed by user typing
baseNewTextOffset = j + effectiveText.length;
previousOurE = curOurE;
ourIdx++;
continue;
}

// Try partial consumption: remaining user text is a prefix of model text
const remainingBase = baseEdit.newText.substring(baseNewTextOffset);
if (remainingBase.length > 0 && effectiveText.startsWith(remainingBase)) {
const consumedFromNewText = Math.max(0, remainingBase.length - gapText.length);
const unconsumedNewText = curOurE.newText.substring(consumedFromNewText);
if (unconsumedNewText.length > 0) {
newEdits.push(new AnnotatedStringReplacement(
OffsetRange.emptyAt(baseEdit.replaceRange.start + offset + baseEdit.newText.length),
unconsumedNewText,
curOurE.data,
));
}
baseNewTextOffset = baseEdit.newText.length;
previousOurE = curOurE;
ourIdx++;
break;
}

// Conflicting
return undefined;
}

// Verify trailing gap in strict mode: any original content between the
// last consumed our-edit and the end of the base range must be preserved.
// Remaining user text beyond the gap is the user's own typing and is fine.
if (baseNewTextOffset < baseEdit.newText.length && resolution === 'strict') {
const lastOurEnd = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;
const trailingGap = content.substring(lastOurEnd, baseEdit.replaceRange.endExclusive);
if (trailingGap.length > 0) {
const remainingBase = baseEdit.newText.substring(baseNewTextOffset);
if (!remainingBase.startsWith(trailingGap)) {
return undefined;
}
}
}

baseIdx++;
offset += baseEdit.newText.length - baseEdit.replaceRange.length;
} else {
// Conflicting
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class NextEditCache extends Disposable {
private _getNesRebaseConfigs(): NesRebaseConfigs {
return {
absorbSubsequenceTyping: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAbsorbSubsequenceTyping, this._expService),
reverseAgreement: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsReverseAgreement, this._expService),
};
}

Expand Down
13 changes: 10 additions & 3 deletions extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,22 @@ export class RebaseFailureInfo implements MarkdownLoggable {

lines.push(`\tconst currentSelection = [${this.currentSelection.map(s => `new OffsetRange(${s.start}, ${s.endExclusive})`).join(', ')}];`);

if (this.nesRebaseConfigs.absorbSubsequenceTyping) {
lines.push(`\tconst nesConfigs = { absorbSubsequenceTyping: ${this.nesRebaseConfigs.absorbSubsequenceTyping} };`);
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(', ')} };`);
}

lines.push('');
lines.push('\tconst logger = new TestLogService();');
lines.push('\texpect(userEditSince.apply(originalDocument)).toBe(currentDocumentContent);');

const configsArg = this.nesRebaseConfigs.absorbSubsequenceTyping ? ', nesConfigs' : '';
const configsArg = (this.nesRebaseConfigs.absorbSubsequenceTyping || this.nesRebaseConfigs.reverseAgreement) ? ', nesConfigs' : '';
lines.push(`\texpect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger${configsArg})).toMatchInlineSnapshot();`);

lines.push('});');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1091,4 +1091,245 @@ class Point3D {
expect(lenient2?.apply(current2)).toStrictEqual(applied);
expect(lenient2?.removeCommonSuffixAndPrefix(current2).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength + 1}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);
});

test('reverse agreement: user typed more than model predicted at same position', () => {
// Model predicts two edits: insert "{" and insert body.
// User typed "{\n\t" which covers the first edit and the start of the second.
// Rebase should succeed, offering the unconsumed portion of the second edit.
const originalDocument = 'class Fibonacci \n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),
StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
]);
const currentDocumentContent = 'class Fibonacci {\n\t\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
// Without flag: rebase fails
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');
// With flag: rebase succeeds
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
expect(res).toBeTypeOf('object');
const result = res as Exclude<typeof res, string>;
expect(result.length).toBe(1);
expect(result[0].rebasedEditIndex).toBe(1);
// The unconsumed portion of the body edit should be offered
expect(result[0].rebasedEdit.newText).toContain('private memo');
});

test('reverse agreement: user typed exactly the first model edit', () => {
// User typed exactly "{" which is the model's first edit.
// The second edit (body) should be offered in full.
// Note: this case is actually handled by the existing forward agreement path
// (user text length == model text length), so it works regardless of the flag.
const originalDocument = 'class Foo \n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
StringReplacement.replace(OffsetRange.emptyAt(12), '\n\tbar(): void {}\n}'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
]);
const currentDocumentContent = 'class Foo {\n';

const logger = new TestLogService();
// Works without reverse agreement flag (handled by forward agreement)
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger);
expect(res).toBeTypeOf('object');
const result = res as Exclude<typeof res, string>;
expect(result.length).toBe(1);
expect(result[0].rebasedEditIndex).toBe(1);
expect(result[0].rebasedEdit.newText).toContain('bar(): void {}');
});

test('reverse agreement: user typed completely different text — should conflict', () => {
// Model: "class Foo " → "class Foo {"
// User: "class Foo " → "class Foo XYZ"
// "XYZ" is NOT found in "{", so this should fail.
const originalDocument = 'class Foo \n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo XYZ'),
]);
const currentDocumentContent = 'class Foo XYZ\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
});

test('reverse agreement: user typed text that accidentally contains model text as substring', () => {
// Model: replace [0,5) "hello" → "hello{" (diff: insert "{" at 5), then insert body at 6.
// User: replace [0,5) "hello" → "helloXX{YY" (diff: insert "XX{YY" at 5).
// The model's first diff ("{") IS found in user's "XX{YY" at offset 2, so it's consumed.
// But the model's second edit ("\n\tworld\n}") can't be found in the remaining
// user text "YY" — partial consumption also fails ("YY" doesn't start with "\n\tworld\n}").
// So the rebase correctly fails for the second edit.
const originalDocument = 'hello\n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 5), 'hello{'),
StringReplacement.replace(OffsetRange.emptyAt(6), '\n\tworld\n}'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 5), 'helloXX{YY'),
]);
const currentDocumentContent = 'helloXX{YY\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
// Fails because user's remaining text "YY" doesn't match model's second edit
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
});

test('reverse agreement: user typed text with model text at large offset — strict rejects', () => {
// Model: "a" → "a{"
// User: "a" → "a" + "X".repeat(15) + "{"
// The "{" is at offset 15 into the user text, which exceeds maxAgreementOffset (10).
// Strict should reject; lenient should also fail since there's no lenient fallback
// in the reverse branch.
const pad = 'X'.repeat(maxAgreementOffset + 1);
const originalDocument = 'a\n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 1), 'a{'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 1), 'a' + pad + '{'),
]);
const currentDocumentContent = 'a' + pad + '{\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
});

test('reverse agreement: user typed long text at small offset — strict rejects imperfect agreement', () => {
// Model: "a" → "a{"
// User: "a" → "aX" + "{".repeat(maxImperfectAgreementLength + 1)
// The model text "{" is found at offset 1 (> 0) and the effective text length
// is 1 (≤ maxImperfectAgreementLength), so this should pass strict.
// But if effectiveText were longer...
const longText = 'Z'.repeat(maxImperfectAgreementLength + 1);
const originalDocument = 'a\n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 1), 'a' + longText),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 1), 'aX' + longText),
]);
const currentDocumentContent = 'aX' + longText + '\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
// offset = 1 > 0, effectiveText.length = longText.length > maxImperfectAgreementLength
// → strict rejected
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
});

test('reverse agreement: all model edits fully consumed by user — no rebased edit emitted', () => {
// Model predicts single edit: insert "{\n\t"
// User typed "{\n\tfoo\n}" which fully contains "{\n\t"
// All model edits consumed → nothing to offer
const originalDocument = 'fn \n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\t'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\tfoo\n}'),
]);
const currentDocumentContent = 'fn {\n\tfoo\n}\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
// Without flag: rebase fails
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');
// With flag: succeeds with no edits to offer
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
expect(res).toBeTypeOf('object');
const result = res as Exclude<typeof res, string>;
// The single model edit was fully consumed — nothing left to suggest
expect(result.length).toBe(0);
});

test('reverse agreement: consistency check — rebased edit applied to current doc produces expected result', () => {
// This is the key correctness check: applying the rebased edit to the current
// document should produce the same result as applying the original edits to
// the original document.
const originalDocument = 'class Fibonacci \n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),
StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
]);
const currentDocumentContent = 'class Fibonacci {\n\t\n';
const nesConfigs = { reverseAgreement: true };

// Expected final: apply both model edits in sequence to original
const expectedFinal = new StringEdit([originalEdits[0]]).apply(originalDocument);
const expectedFinal2 = new StringEdit([originalEdits[1]]).apply(expectedFinal);

const logger = new TestLogService();
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
expect(res).toBeTypeOf('object');
const result = res as Exclude<typeof res, string>;
expect(result.length).toBe(1);

// Apply rebased edit to current document
const actualFinal = StringEdit.single(result[0].rebasedEdit).apply(currentDocumentContent);
expect(actualFinal).toBe(expectedFinal2);
});

test('reverse agreement: pure inserts at same position — user insert is superset of model insert', () => {
// Both edits are pure inserts at position 5.
// Model inserts "X", user inserts "XY".
// After removeCommonSuffixAndPrefix on user edit:
// user edit: insert at 5 → "XY", model edit: insert at 5 → "X"
// These have equal replaceRange (both emptyAt(5)).
// The reverse branch should fire: "X" found in "XY" at offset 0 → consumed.
// Nothing left to suggest from this model edit.
const originalDocument = 'hello world\n';
const suggestedEdit = StringEdit.create([
StringReplacement.replace(OffsetRange.emptyAt(5), 'X'),
]);
const userEdit = StringEdit.create([
StringReplacement.replace(OffsetRange.emptyAt(5), 'XY'),
]);
const current = userEdit.apply(originalDocument);
expect(current).toBe('helloXY world\n');

// Without flag: rebase fails
expect(tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict')).toBeUndefined();
// With flag: model edit fully consumed → empty result
const nesConfigs = { reverseAgreement: true };
const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);
expect(res).toBeDefined();
expect(res!.replacements.length).toBe(0);
});

test('reverse agreement: does NOT fire when ranges differ', () => {
// Model replaces [0,3), user replaces [0,5) — different ranges.
// The reverse branch requires equal ranges, so this should NOT trigger it.
// Instead, this falls through to the conflict branch.
const originalDocument = 'abcde\n';
const originalEdits = [
StringReplacement.replace(new OffsetRange(0, 3), 'XYZ'),
];
const userEditSince = StringEdit.create([
StringReplacement.replace(new OffsetRange(0, 5), 'XYZWV'),
]);
const currentDocumentContent = 'XYZWV\n';
const nesConfigs = { reverseAgreement: true };

const logger = new TestLogService();
// The ranges don't match after removeCommonSuffixAndPrefix, so this conflicts
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
});
});
Loading
Loading