Skip to content

Commit 1522ae8

Browse files
authored
nes: patch: fix: don't yield malformed edits on fetch fail/cancellation (#4904)
1 parent 0e36b0a commit 1522ae8

File tree

3 files changed

+99
-1
lines changed

3 files changed

+99
-1
lines changed

src/extension/xtab/node/xtabCustomDiffPatchResponseHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,16 @@ export class XtabCustomDiffPatchResponseHandler {
7070
workspaceRoot: URI | undefined,
7171
window: OffsetRange | undefined,
7272
parentTracer: ILogger,
73+
getFetchFailure?: () => NoNextEditReason | undefined,
7374
): AsyncGenerator<StreamedEdit, NoNextEditReason, void> {
7475
const tracer = parentTracer.createSubLogger(['XtabCustomDiffPatchResponseHandler', 'handleResponse']);
7576
const activeDocRelativePath = toUniquePath(activeDocumentId, workspaceRoot?.path);
7677
try {
7778
for await (const edit of XtabCustomDiffPatchResponseHandler.extractEdits(linesStream)) {
79+
const fetchFailure = getFetchFailure?.();
80+
if (fetchFailure) {
81+
return fetchFailure;
82+
}
7883
const targetDocument = edit.filePath === activeDocRelativePath
7984
? activeDocumentId
8085
: XtabCustomDiffPatchResponseHandler.resolveTargetDocument(edit.filePath, workspaceRoot);

src/extension/xtab/node/xtabProvider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,7 @@ export class XtabProvider implements IStatelessNextEditProvider {
850850
activeDoc.workspaceRoot,
851851
pseudoEditWindow,
852852
tracer,
853+
() => chatResponseFailure ? mapChatFetcherErrorToNoNextEditReason(chatResponseFailure) : undefined,
853854
);
854855
} else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) {
855856
const linesIter = linesStream[Symbol.asyncIterator]();

src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,37 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { describe, expect, it } from 'vitest';
6+
import { beforeEach, describe, expect, it } from 'vitest';
7+
import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';
8+
import { NoNextEditReason, StreamedEdit } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';
9+
import { TestLogService } from '../../../../platform/testing/common/testLogService';
710
import { AsyncIterUtils } from '../../../../util/common/asyncIterableUtils';
11+
import { Position } from '../../../../util/vs/editor/common/core/position';
12+
import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText';
13+
import { ensureDependenciesAreSet } from '../../../../util/vs/editor/common/core/text/positionToOffset';
14+
import { CurrentDocument } from '../../common/xtabCurrentDocument';
815
import { XtabCustomDiffPatchResponseHandler } from '../../node/xtabCustomDiffPatchResponseHandler';
916

17+
async function consumeHandleResponse(
18+
...args: Parameters<typeof XtabCustomDiffPatchResponseHandler.handleResponse>
19+
): Promise<{ edits: StreamedEdit[]; returnValue: NoNextEditReason }> {
20+
const gen = XtabCustomDiffPatchResponseHandler.handleResponse(...args);
21+
const edits: StreamedEdit[] = [];
22+
for (; ;) {
23+
const result = await gen.next();
24+
if (result.done) {
25+
return { edits, returnValue: result.value };
26+
}
27+
edits.push(result.value);
28+
}
29+
}
30+
1031
describe('XtabCustomDiffPatchResponseHandler', () => {
1132

33+
beforeEach(() => {
34+
ensureDependenciesAreSet();
35+
});
36+
1237
async function collectPatches(patchText: string): Promise<string> {
1338
const linesStream = AsyncIterUtils.fromArray(patchText.split('\n'));
1439
const patches = await AsyncIterUtils.toArray(XtabCustomDiffPatchResponseHandler.extractEdits(linesStream));
@@ -113,4 +138,71 @@ another_file.js:
113138
+Old line 1"
114139
`);
115140
});
141+
142+
it('stops yielding edits when getFetchFailure returns a failure', async () => {
143+
const patchText = `/file.ts:0
144+
-old
145+
+new
146+
/file.ts:5
147+
-another old
148+
+another new`;
149+
const linesStream = AsyncIterUtils.fromArray(patchText.split('\n'));
150+
const docId = DocumentId.create('file:///file.ts');
151+
const documentBeforeEdits = new CurrentDocument(new StringText('old\n'), new Position(1, 1));
152+
153+
let yieldCount = 0;
154+
const cancellationReason = new NoNextEditReason.GotCancelled('afterFetchCall');
155+
156+
const { edits, returnValue } = await consumeHandleResponse(
157+
linesStream,
158+
documentBeforeEdits,
159+
docId,
160+
undefined,
161+
undefined,
162+
new TestLogService(),
163+
() => {
164+
// Signal failure after the first edit has been yielded
165+
if (yieldCount++ > 0) {
166+
return cancellationReason;
167+
}
168+
return undefined;
169+
},
170+
);
171+
172+
expect(edits).toHaveLength(1);
173+
expect(returnValue).toBe(cancellationReason);
174+
});
175+
176+
it('does not yield incomplete patch when fetch is cancelled mid-stream', async () => {
177+
// Stream is truncated before the last line "+one more new" is emitted,
178+
// simulating a cancellation that cuts off the response mid-patch.
179+
const truncatedPatchText = `/file.ts:0
180+
-old
181+
+new
182+
/file.ts:5
183+
-another old
184+
+another new`;
185+
const linesStream = AsyncIterUtils.fromArray(truncatedPatchText.split('\n'));
186+
const docId = DocumentId.create('file:///file.ts');
187+
const documentBeforeEdits = new CurrentDocument(new StringText('old\n'), new Position(1, 1));
188+
189+
const cancellationReason = new NoNextEditReason.GotCancelled('afterFetchCall');
190+
191+
const { edits, returnValue } = await consumeHandleResponse(
192+
linesStream,
193+
documentBeforeEdits,
194+
docId,
195+
undefined,
196+
undefined,
197+
new TestLogService(),
198+
() => {
199+
// Fetch was cancelled — the full patch had "+one more new" but the
200+
// stream was resolved early so it's missing from the second patch.
201+
return cancellationReason;
202+
},
203+
);
204+
205+
expect(edits).toHaveLength(0);
206+
expect(returnValue).toBe(cancellationReason);
207+
});
116208
});

0 commit comments

Comments
 (0)