Path: blob/main/extensions/copilot/test/pipeline/responseStep.ts
13389 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ResponseFormat } from '../../src/platform/inlineEdits/common/dataTypes/xtabPromptOptions';6import { assertNever } from '../../src/util/vs/base/common/assert';7import { splitLines } from '../../src/util/vs/base/common/strings';8import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText';910export interface IGeneratedResponse {11readonly assistant: string;12}1314/**15* Apply offset-based edits to document content.16* Edits are sorted by offset descending so earlier positions remain valid.17*/18export function applyEditsToContent(19content: string,20edits: readonly (readonly [start: number, endEx: number, text: string])[],21): string {22const sorted = [...edits].sort((a, b) => b[0] - a[0]);23let result = content;24for (const [start, endEx, text] of sorted) {25result = result.substring(0, start) + text + result.substring(endEx);26}27return result;28}2930/**31* Format edits as PatchBased02 custom diff patches.32* Applies all edits to get the final document, then does a line-level diff33* and groups consecutive changed lines into `filename:linenum\n-old\n+new` patches.34*/35export function formatAsCustomDiffPatch(36oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[],37docContent: string,38filePath: string,39): string {40const modifiedContent = applyEditsToContent(docContent, oracleEdits);4142const oldLines = splitLines(docContent);43const newLines = splitLines(modifiedContent);4445const patches: string[] = [];46const maxLen = Math.max(oldLines.length, newLines.length);4748let i = 0;49while (i < maxLen) {50const oldLine = i < oldLines.length ? oldLines[i] : undefined;51const newLine = i < newLines.length ? newLines[i] : undefined;5253if (oldLine === newLine) {54i++;55continue;56}5758// Collect the full run of changed lines59const startLine = i;60const removedLines: string[] = [];61const addedLines: string[] = [];6263while (i < maxLen) {64const ol = i < oldLines.length ? oldLines[i] : undefined;65const nl = i < newLines.length ? newLines[i] : undefined;6667if (ol === nl) {68break;69}7071if (ol !== undefined) {72removedLines.push(ol);73}74if (nl !== undefined) {75addedLines.push(nl);76}77i++;78}7980// PatchBased02 handler requires both removed and added lines81if (removedLines.length > 0 && addedLines.length > 0) {82patches.push([83`${filePath}:${startLine}`,84...removedLines.map(l => `-${l}`),85...addedLines.map(l => `+${l}`),86].join('\n'));87} else if (removedLines.length > 0) {88patches.push([89`${filePath}:${startLine}`,90...removedLines.map(l => `-${l}`),91`+`,92].join('\n'));93} else if (addedLines.length > 0) {94// Pure insertion — use previous line as anchor95const anchorLine = startLine > 0 ? oldLines[startLine - 1] : '';96patches.push([97`${filePath}:${Math.max(0, startLine - 1)}`,98`-${anchorLine}`,99`+${anchorLine}`,100...addedLines.map(l => `+${l}`),101].join('\n'));102}103}104105return patches.join('\n');106}107108/**109* Parse the edit window content from a generated user prompt.110* Looks for content between `<|code_to_edit|>` and `<|/code_to_edit|>` tags.111*/112export function parseEditWindowFromPrompt(userPrompt: string): {113/** The raw lines between the tags (may include line numbers) */114lines: string[];115/** Number of lines in the edit window */116lineCount: number;117} | undefined {118const startTag = '<|code_to_edit|>';119const endTag = '<|/code_to_edit|>';120121const startIdx = userPrompt.indexOf(startTag);122const endIdx = userPrompt.indexOf(endTag);123124if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {125return undefined;126}127128const windowContent = userPrompt.substring(startIdx + startTag.length, endIdx);129const lines = windowContent.split('\n');130131// Trim leading/trailing empty lines from tag placement132while (lines.length > 0 && lines[0].trim() === '') {133lines.shift();134}135while (lines.length > 0 && lines[lines.length - 1].trim() === '') {136lines.pop();137}138139return { lines, lineCount: lines.length };140}141142/**143* Format edits as Xtab275 edit-window content.144* Applies edits and re-extracts the edit window lines,145* adjusting for line count changes within the window.146*/147export function formatAsEditWindowOnly(148oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[],149docContent: string,150editWindowStartLine: number,151editWindowLineCount: number,152): string {153const transformer = new StringText(docContent).getTransformer();154let windowStart = editWindowStartLine;155let windowEnd = editWindowStartLine + editWindowLineCount;156157// Ensure the window covers all oracle edits158for (const [start, endEx] of oracleEdits) {159const editStartLine = transformer.getPosition(start).lineNumber - 1;160const editEndLine = transformer.getPosition(endEx).lineNumber - 1;161if (editStartLine < windowStart) {162windowStart = editStartLine;163}164if (editEndLine >= windowEnd) {165windowEnd = editEndLine + 1;166}167}168169const modifiedContent = applyEditsToContent(docContent, oracleEdits);170const modifiedLines = splitLines(modifiedContent);171172// Calculate net line change from edits overlapping the window173let netLineChange = 0;174for (const [start, endEx, text] of oracleEdits) {175const editStartLine = transformer.getPosition(start).lineNumber - 1;176const editEndLine = transformer.getPosition(endEx).lineNumber - 1;177178if (editStartLine < windowEnd && editEndLine >= windowStart) {179const oldLineCount = splitLines(docContent.substring(start, endEx)).length;180const newLineCount = text.length > 0 ? splitLines(text).length : 0;181const effectiveOldCount = (endEx - start) === 0 ? 0 : oldLineCount;182netLineChange += newLineCount - effectiveOldCount;183}184}185186const newEndLine = Math.min(windowEnd + netLineChange, modifiedLines.length);187const windowLines = modifiedLines.slice(windowStart, newEndLine);188189return windowLines.join('\n');190}191192/**193* Find the edit window start line by matching the edit window content from the194* prompt against the document content.195*/196export function findEditWindowStartLine(197docContent: string,198editWindowLines: string[],199): number {200if (editWindowLines.length === 0) {201return 0;202}203204const docLines = splitLines(docContent);205206// Strip line numbers and <|cursor|> tags for matching against document content207const cleanedWindowLines = editWindowLines.map(stripLineNumber);208const cursorTag = '<|cursor|>';209const matchLines = cleanedWindowLines.map(l => l.replace(cursorTag, ''));210211const firstWindowLine = matchLines[0];212for (let i = 0; i <= docLines.length - matchLines.length; i++) {213if (docLines[i] === firstWindowLine) {214// Check if all subsequent lines match215let allMatch = true;216for (let j = 1; j < matchLines.length; j++) {217if (docLines[i + j] !== matchLines[j]) {218allMatch = false;219break;220}221}222if (allMatch) {223return i;224}225}226}227228// Fallback: try to extract line number from the first edit window line229const lineNumMatch = editWindowLines[0].match(/^(\d+)\|\s?/);230if (lineNumMatch) {231return parseInt(lineNumMatch[1], 10) - 1; // Convert 1-based to 0-based232}233234return 0;235}236237function stripLineNumber(line: string): string {238const match = line.match(/^\d+\|\s?/);239if (match) {240return line.substring(match[0].length);241}242return line;243}244245/**246* Format edits as the expected assistant response for the given response format.247*248* Only CustomDiffPatch and EditWindowOnly are supported.249*/250export function generateResponse(251responseFormat: ResponseFormat,252edits: readonly (readonly [start: number, endEx: number, text: string])[] | undefined,253docContent: string,254filePath: string,255userPrompt: string,256): IGeneratedResponse | { error: string } {257if (!edits || edits.length === 0) {258return { error: `No edits available (file: ${filePath})` };259}260261switch (responseFormat) {262case ResponseFormat.CustomDiffPatch:263return generateCustomDiffPatchResponse(edits, docContent, filePath);264case ResponseFormat.EditWindowOnly:265return generateEditWindowOnlyResponse(edits, docContent, filePath, userPrompt);266case ResponseFormat.UnifiedWithXml:267case ResponseFormat.CodeBlock:268case ResponseFormat.EditWindowWithEditIntent:269case ResponseFormat.EditWindowWithEditIntentShort:270return { error: `Unsupported response format: ${responseFormat}` };271default:272assertNever(responseFormat);273}274}275276function generateCustomDiffPatchResponse(277edits: readonly (readonly [start: number, endEx: number, text: string])[],278docContent: string,279filePath: string,280): IGeneratedResponse | { error: string } {281const assistant = formatAsCustomDiffPatch(edits, docContent, filePath);282if (!assistant) {283return { error: `formatAsCustomDiffPatch produced empty result (file: ${filePath}, ${edits.length} edits)` };284}285return { assistant };286}287288function generateEditWindowOnlyResponse(289edits: readonly (readonly [start: number, endEx: number, text: string])[],290docContent: string,291filePath: string,292userPrompt: string,293): IGeneratedResponse | { error: string } {294const editWindow = parseEditWindowFromPrompt(userPrompt);295296let startLine: number;297let lineCount: number;298299if (editWindow) {300startLine = findEditWindowStartLine(docContent, editWindow.lines);301lineCount = editWindow.lineCount;302} else {303const transformer = new StringText(docContent).getTransformer();304const editStartLine = transformer.getPosition(edits[0][0]).lineNumber - 1;305const lastEdit = edits[edits.length - 1];306const editEndLine = transformer.getPosition(lastEdit[1]).lineNumber - 1;307const editSpan = editEndLine - editStartLine + 1;308const padding = Math.max(10, Math.floor(editSpan * 0.5));309const docLines = splitLines(docContent);310startLine = Math.max(0, editStartLine - padding);311lineCount = Math.min(editSpan + padding * 2, docLines.length - startLine);312}313314const assistant = formatAsEditWindowOnly(edits, docContent, startLine, lineCount);315if (!assistant || !assistant.trim()) {316return { error: `formatAsEditWindowOnly produced empty result (file: ${filePath}, ${edits.length} edits, window: ${startLine}+${lineCount})` };317}318return { assistant };319}320321export interface IResponseGenerationInput {322readonly index: number;323readonly oracleEdits: readonly (readonly [start: number, endEx: number, text: string])[] | undefined;324readonly docContent: string;325readonly filePath: string;326readonly userPrompt: string;327}328329export function generateAllResponses(330responseFormat: ResponseFormat,331inputs: readonly IResponseGenerationInput[],332): {333responses: { index: number; response: IGeneratedResponse }[];334errors: { index: number; error: string }[];335} {336const responses: { index: number; response: IGeneratedResponse }[] = [];337const errors: { index: number; error: string }[] = [];338339for (const input of inputs) {340const result = generateResponse(341responseFormat,342input.oracleEdits, input.docContent, input.filePath,343input.userPrompt,344);345if ('error' in result) {346errors.push({ index: input.index, error: result.error });347} else {348responses.push({ index: input.index, response: result });349}350}351352return { responses, errors };353}354355356