Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/pipeline/responseStep.ts
13389 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { ResponseFormat } from '../../src/platform/inlineEdits/common/dataTypes/xtabPromptOptions';
7
import { assertNever } from '../../src/util/vs/base/common/assert';
8
import { splitLines } from '../../src/util/vs/base/common/strings';
9
import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText';
10
11
export interface IGeneratedResponse {
12
readonly assistant: string;
13
}
14
15
/**
16
* Apply offset-based edits to document content.
17
* Edits are sorted by offset descending so earlier positions remain valid.
18
*/
19
export function applyEditsToContent(
20
content: string,
21
edits: readonly (readonly [start: number, endEx: number, text: string])[],
22
): string {
23
const sorted = [...edits].sort((a, b) => b[0] - a[0]);
24
let result = content;
25
for (const [start, endEx, text] of sorted) {
26
result = result.substring(0, start) + text + result.substring(endEx);
27
}
28
return result;
29
}
30
31
/**
32
* Format edits as PatchBased02 custom diff patches.
33
* Applies all edits to get the final document, then does a line-level diff
34
* and groups consecutive changed lines into `filename:linenum\n-old\n+new` patches.
35
*/
36
export function formatAsCustomDiffPatch(
37
oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[],
38
docContent: string,
39
filePath: string,
40
): string {
41
const modifiedContent = applyEditsToContent(docContent, oracleEdits);
42
43
const oldLines = splitLines(docContent);
44
const newLines = splitLines(modifiedContent);
45
46
const patches: string[] = [];
47
const maxLen = Math.max(oldLines.length, newLines.length);
48
49
let i = 0;
50
while (i < maxLen) {
51
const oldLine = i < oldLines.length ? oldLines[i] : undefined;
52
const newLine = i < newLines.length ? newLines[i] : undefined;
53
54
if (oldLine === newLine) {
55
i++;
56
continue;
57
}
58
59
// Collect the full run of changed lines
60
const startLine = i;
61
const removedLines: string[] = [];
62
const addedLines: string[] = [];
63
64
while (i < maxLen) {
65
const ol = i < oldLines.length ? oldLines[i] : undefined;
66
const nl = i < newLines.length ? newLines[i] : undefined;
67
68
if (ol === nl) {
69
break;
70
}
71
72
if (ol !== undefined) {
73
removedLines.push(ol);
74
}
75
if (nl !== undefined) {
76
addedLines.push(nl);
77
}
78
i++;
79
}
80
81
// PatchBased02 handler requires both removed and added lines
82
if (removedLines.length > 0 && addedLines.length > 0) {
83
patches.push([
84
`${filePath}:${startLine}`,
85
...removedLines.map(l => `-${l}`),
86
...addedLines.map(l => `+${l}`),
87
].join('\n'));
88
} else if (removedLines.length > 0) {
89
patches.push([
90
`${filePath}:${startLine}`,
91
...removedLines.map(l => `-${l}`),
92
`+`,
93
].join('\n'));
94
} else if (addedLines.length > 0) {
95
// Pure insertion — use previous line as anchor
96
const anchorLine = startLine > 0 ? oldLines[startLine - 1] : '';
97
patches.push([
98
`${filePath}:${Math.max(0, startLine - 1)}`,
99
`-${anchorLine}`,
100
`+${anchorLine}`,
101
...addedLines.map(l => `+${l}`),
102
].join('\n'));
103
}
104
}
105
106
return patches.join('\n');
107
}
108
109
/**
110
* Parse the edit window content from a generated user prompt.
111
* Looks for content between `<|code_to_edit|>` and `<|/code_to_edit|>` tags.
112
*/
113
export function parseEditWindowFromPrompt(userPrompt: string): {
114
/** The raw lines between the tags (may include line numbers) */
115
lines: string[];
116
/** Number of lines in the edit window */
117
lineCount: number;
118
} | undefined {
119
const startTag = '<|code_to_edit|>';
120
const endTag = '<|/code_to_edit|>';
121
122
const startIdx = userPrompt.indexOf(startTag);
123
const endIdx = userPrompt.indexOf(endTag);
124
125
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
126
return undefined;
127
}
128
129
const windowContent = userPrompt.substring(startIdx + startTag.length, endIdx);
130
const lines = windowContent.split('\n');
131
132
// Trim leading/trailing empty lines from tag placement
133
while (lines.length > 0 && lines[0].trim() === '') {
134
lines.shift();
135
}
136
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
137
lines.pop();
138
}
139
140
return { lines, lineCount: lines.length };
141
}
142
143
/**
144
* Format edits as Xtab275 edit-window content.
145
* Applies edits and re-extracts the edit window lines,
146
* adjusting for line count changes within the window.
147
*/
148
export function formatAsEditWindowOnly(
149
oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[],
150
docContent: string,
151
editWindowStartLine: number,
152
editWindowLineCount: number,
153
): string {
154
const transformer = new StringText(docContent).getTransformer();
155
let windowStart = editWindowStartLine;
156
let windowEnd = editWindowStartLine + editWindowLineCount;
157
158
// Ensure the window covers all oracle edits
159
for (const [start, endEx] of oracleEdits) {
160
const editStartLine = transformer.getPosition(start).lineNumber - 1;
161
const editEndLine = transformer.getPosition(endEx).lineNumber - 1;
162
if (editStartLine < windowStart) {
163
windowStart = editStartLine;
164
}
165
if (editEndLine >= windowEnd) {
166
windowEnd = editEndLine + 1;
167
}
168
}
169
170
const modifiedContent = applyEditsToContent(docContent, oracleEdits);
171
const modifiedLines = splitLines(modifiedContent);
172
173
// Calculate net line change from edits overlapping the window
174
let netLineChange = 0;
175
for (const [start, endEx, text] of oracleEdits) {
176
const editStartLine = transformer.getPosition(start).lineNumber - 1;
177
const editEndLine = transformer.getPosition(endEx).lineNumber - 1;
178
179
if (editStartLine < windowEnd && editEndLine >= windowStart) {
180
const oldLineCount = splitLines(docContent.substring(start, endEx)).length;
181
const newLineCount = text.length > 0 ? splitLines(text).length : 0;
182
const effectiveOldCount = (endEx - start) === 0 ? 0 : oldLineCount;
183
netLineChange += newLineCount - effectiveOldCount;
184
}
185
}
186
187
const newEndLine = Math.min(windowEnd + netLineChange, modifiedLines.length);
188
const windowLines = modifiedLines.slice(windowStart, newEndLine);
189
190
return windowLines.join('\n');
191
}
192
193
/**
194
* Find the edit window start line by matching the edit window content from the
195
* prompt against the document content.
196
*/
197
export function findEditWindowStartLine(
198
docContent: string,
199
editWindowLines: string[],
200
): number {
201
if (editWindowLines.length === 0) {
202
return 0;
203
}
204
205
const docLines = splitLines(docContent);
206
207
// Strip line numbers and <|cursor|> tags for matching against document content
208
const cleanedWindowLines = editWindowLines.map(stripLineNumber);
209
const cursorTag = '<|cursor|>';
210
const matchLines = cleanedWindowLines.map(l => l.replace(cursorTag, ''));
211
212
const firstWindowLine = matchLines[0];
213
for (let i = 0; i <= docLines.length - matchLines.length; i++) {
214
if (docLines[i] === firstWindowLine) {
215
// Check if all subsequent lines match
216
let allMatch = true;
217
for (let j = 1; j < matchLines.length; j++) {
218
if (docLines[i + j] !== matchLines[j]) {
219
allMatch = false;
220
break;
221
}
222
}
223
if (allMatch) {
224
return i;
225
}
226
}
227
}
228
229
// Fallback: try to extract line number from the first edit window line
230
const lineNumMatch = editWindowLines[0].match(/^(\d+)\|\s?/);
231
if (lineNumMatch) {
232
return parseInt(lineNumMatch[1], 10) - 1; // Convert 1-based to 0-based
233
}
234
235
return 0;
236
}
237
238
function stripLineNumber(line: string): string {
239
const match = line.match(/^\d+\|\s?/);
240
if (match) {
241
return line.substring(match[0].length);
242
}
243
return line;
244
}
245
246
/**
247
* Format edits as the expected assistant response for the given response format.
248
*
249
* Only CustomDiffPatch and EditWindowOnly are supported.
250
*/
251
export function generateResponse(
252
responseFormat: ResponseFormat,
253
edits: readonly (readonly [start: number, endEx: number, text: string])[] | undefined,
254
docContent: string,
255
filePath: string,
256
userPrompt: string,
257
): IGeneratedResponse | { error: string } {
258
if (!edits || edits.length === 0) {
259
return { error: `No edits available (file: ${filePath})` };
260
}
261
262
switch (responseFormat) {
263
case ResponseFormat.CustomDiffPatch:
264
return generateCustomDiffPatchResponse(edits, docContent, filePath);
265
case ResponseFormat.EditWindowOnly:
266
return generateEditWindowOnlyResponse(edits, docContent, filePath, userPrompt);
267
case ResponseFormat.UnifiedWithXml:
268
case ResponseFormat.CodeBlock:
269
case ResponseFormat.EditWindowWithEditIntent:
270
case ResponseFormat.EditWindowWithEditIntentShort:
271
return { error: `Unsupported response format: ${responseFormat}` };
272
default:
273
assertNever(responseFormat);
274
}
275
}
276
277
function generateCustomDiffPatchResponse(
278
edits: readonly (readonly [start: number, endEx: number, text: string])[],
279
docContent: string,
280
filePath: string,
281
): IGeneratedResponse | { error: string } {
282
const assistant = formatAsCustomDiffPatch(edits, docContent, filePath);
283
if (!assistant) {
284
return { error: `formatAsCustomDiffPatch produced empty result (file: ${filePath}, ${edits.length} edits)` };
285
}
286
return { assistant };
287
}
288
289
function generateEditWindowOnlyResponse(
290
edits: readonly (readonly [start: number, endEx: number, text: string])[],
291
docContent: string,
292
filePath: string,
293
userPrompt: string,
294
): IGeneratedResponse | { error: string } {
295
const editWindow = parseEditWindowFromPrompt(userPrompt);
296
297
let startLine: number;
298
let lineCount: number;
299
300
if (editWindow) {
301
startLine = findEditWindowStartLine(docContent, editWindow.lines);
302
lineCount = editWindow.lineCount;
303
} else {
304
const transformer = new StringText(docContent).getTransformer();
305
const editStartLine = transformer.getPosition(edits[0][0]).lineNumber - 1;
306
const lastEdit = edits[edits.length - 1];
307
const editEndLine = transformer.getPosition(lastEdit[1]).lineNumber - 1;
308
const editSpan = editEndLine - editStartLine + 1;
309
const padding = Math.max(10, Math.floor(editSpan * 0.5));
310
const docLines = splitLines(docContent);
311
startLine = Math.max(0, editStartLine - padding);
312
lineCount = Math.min(editSpan + padding * 2, docLines.length - startLine);
313
}
314
315
const assistant = formatAsEditWindowOnly(edits, docContent, startLine, lineCount);
316
if (!assistant || !assistant.trim()) {
317
return { error: `formatAsEditWindowOnly produced empty result (file: ${filePath}, ${edits.length} edits, window: ${startLine}+${lineCount})` };
318
}
319
return { assistant };
320
}
321
322
export interface IResponseGenerationInput {
323
readonly index: number;
324
readonly oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[] | undefined;
325
readonly docContent: string;
326
readonly filePath: string;
327
readonly userPrompt: string;
328
}
329
330
export function generateAllResponses(
331
responseFormat: ResponseFormat,
332
inputs: readonly IResponseGenerationInput[],
333
): {
334
responses: { index: number; response: IGeneratedResponse }[];
335
errors: { index: number; error: string }[];
336
} {
337
const responses: { index: number; response: IGeneratedResponse }[] = [];
338
const errors: { index: number; error: string }[] = [];
339
340
for (const input of inputs) {
341
const result = generateResponse(
342
responseFormat,
343
input.oracleEdits, input.docContent, input.filePath,
344
input.userPrompt,
345
);
346
if ('error' in result) {
347
errors.push({ index: input.index, error: result.error });
348
} else {
349
responses.push({ index: input.index, response: result });
350
}
351
}
352
353
return { responses, errors };
354
}
355
356