Path: blob/main/extensions/copilot/src/extension/inlineChat2/test/node/inlineChat2Prompt.spec.tsx
13405 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 { expect, suite, test } from 'vitest';6import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';7import { createTextDocumentData, setDocText } from '../../../../util/common/test/shims/textDocument';8import { URI } from '../../../../util/vs/base/common/uri';9import { ExtendedLanguageModelToolResult, LanguageModelTextPart, LanguageModelToolResult, Position, Range } from '../../../../vscodeTypes';10import { FileContextElement, FileSelectionElement, ICompletedToolCallRound, LARGE_FILE_LINE_THRESHOLD, ToolCallRoundsElement } from '../../node/inlineChatPrompt';1112function createSnapshot(content: string, languageId: string = 'typescript'): TextDocumentSnapshot {13const uri = URI.file('/workspace/file.ts');14const docData = createTextDocumentData(uri, content, languageId);15return TextDocumentSnapshot.create(docData.document);16}1718suite('FileContextElement', () => {1920test('cursor at the beginning of the file', async () => {21const content = `line 122line 223line 324line 425line 5`;26const snapshot = createSnapshot(content);27const position = new Position(0, 0);2829const element = new FileContextElement({ snapshot, position });30const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });3132const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';33expect(output).toContain('$CURSOR$');34expect(output).toContain('line 1');35expect(output).toContain('line 2');36expect(output).toContain('line 3');37});3839test('cursor in the middle of a file', async () => {40const content = `line 141line 242line 343line 444line 545line 646line 7`;47const snapshot = createSnapshot(content);48const position = new Position(3, 2); // after "li" in "line 4"4950const element = new FileContextElement({ snapshot, position });51const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });5253const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';54expect(output).toContain('$CURSOR$');55// Should include lines before and after cursor56expect(output).toContain('line 2');57expect(output).toContain('line 3');58// Cursor position (3, 2) splits "line 4" into "li" + "$CURSOR$" + "ne 4"59expect(output).toContain('li$CURSOR$ne 4');60expect(output).toContain('line 5');61expect(output).toContain('line 6');62});6364test('cursor at the end of file', async () => {65const content = `line 166line 267line 368line 469line 5`;70const snapshot = createSnapshot(content);71const position = new Position(4, 6); // end of "line 5"7273const element = new FileContextElement({ snapshot, position });74const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });7576const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';77expect(output).toContain('$CURSOR$');78expect(output).toContain('line 3');79expect(output).toContain('line 4');80expect(output).toContain('line 5');81});8283test('cursor with empty lines - includes extra lines until non-empty', async () => {84const content = `8586line 387line 48889`;90const snapshot = createSnapshot(content);91const position = new Position(2, 0); // start of "line 3"9293const element = new FileContextElement({ snapshot, position });94const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });9596const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';97expect(output).toContain('$CURSOR$');98expect(output).toContain('line 3');99expect(output).toContain('line 4');100});101102test('single line file', async () => {103const content = `only one line`;104const snapshot = createSnapshot(content);105const position = new Position(0, 5); // middle of line106107const element = new FileContextElement({ snapshot, position });108const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });109110const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';111expect(output).toContain('only $CURSOR$one line');112});113114test('cursor position splits text correctly', async () => {115const content = `hello world`;116const snapshot = createSnapshot(content);117const position = new Position(0, 6); // after "hello "118119const element = new FileContextElement({ snapshot, position });120const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });121122const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';123expect(output).toContain('hello $CURSOR$world');124});125});126127suite('FileSelectionElement', () => {128129test('single line selection', async () => {130const content = `line 1131line 2132line 3133line 4134line 5`;135const snapshot = createSnapshot(content);136const selection = new Range(1, 0, 1, 6); // "line 2"137138const element = new FileSelectionElement({ snapshot, selection });139const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });140141const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';142expect(output).toContain('line 2');143expect(output).not.toContain('line 1');144expect(output).not.toContain('line 3');145});146147test('multi-line selection', async () => {148const content = `line 1149line 2150line 3151line 4152line 5`;153const snapshot = createSnapshot(content);154const selection = new Range(1, 0, 3, 6); // "line 2" through "line 4"155156const element = new FileSelectionElement({ snapshot, selection });157const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });158159const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';160expect(output).toContain('line 2');161expect(output).toContain('line 3');162expect(output).toContain('line 4');163expect(output).not.toContain('line 1');164expect(output).not.toContain('line 5');165});166167test('partial line selection extends to full lines', async () => {168const content = `line 1169line 2170line 3`;171const snapshot = createSnapshot(content);172// Select from middle of line 2 to middle of line 2 (partial)173const selection = new Range(1, 2, 1, 4);174175const element = new FileSelectionElement({ snapshot, selection });176const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });177178const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';179// Should include the full line, not just "ne"180expect(output).toContain('line 2');181});182183test('selection at start of file', async () => {184const content = `line 1185line 2186line 3`;187const snapshot = createSnapshot(content);188const selection = new Range(0, 0, 0, 6);189190const element = new FileSelectionElement({ snapshot, selection });191const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });192193const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';194expect(output).toContain('line 1');195expect(output).not.toContain('line 2');196});197198test('selection at end of file', async () => {199const content = `line 1200line 2201line 3`;202const snapshot = createSnapshot(content);203const selection = new Range(2, 0, 2, 6);204205const element = new FileSelectionElement({ snapshot, selection });206const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });207208const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';209expect(output).toContain('line 3');210expect(output).not.toContain('line 2');211});212213test('selection spanning partial lines extends to full lines', async () => {214const content = `first line here215second line here216third line here`;217const snapshot = createSnapshot(content);218// Select from middle of "first" to middle of "second"219const selection = new Range(0, 6, 1, 7);220221const element = new FileSelectionElement({ snapshot, selection });222const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });223224const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';225// Should include full lines226expect(output).toContain('first line here');227expect(output).toContain('second line here');228expect(output).not.toContain('third line here');229});230231test('preserves language id for code block', async () => {232const content = `const x = 1;`;233const snapshot = createSnapshot(content, 'javascript');234const selection = new Range(0, 0, 0, 12);235236const element = new FileSelectionElement({ snapshot, selection });237const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });238239const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';240expect(output).toContain('javascript');241});242});243244// --- Helpers for ToolCallRoundsElement tests245246function makeToolCall(id: string, name: string = 'replace_string_in_file', args: string = '{}') {247return { id, name, arguments: args };248}249250function makeToolResult(text: string, hasError = false): ExtendedLanguageModelToolResult {251const result = new LanguageModelToolResult([new LanguageModelTextPart(text)]) as ExtendedLanguageModelToolResult;252(result as any).hasError = hasError;253return result;254}255256function makeRound(...calls: [string, string][]): ICompletedToolCallRound {257return {258calls: calls.map(([id, resultText]) => [makeToolCall(id), makeToolResult(resultText)])259};260}261262function makeDocument(content: string, languageId = 'typescript', uri = URI.file('/workspace/file.ts')) {263return createTextDocumentData(uri, content, languageId);264}265266suite('ToolCallRoundsElement', () => {267268test('empty rounds renders nothing', async () => {269const doc = makeDocument('const x = 1;');270const element = new ToolCallRoundsElement({271previousRounds: [],272hasFailedEdits: false,273data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,274documentVersionAtRequest: doc.document.version,275isLargeFile: false,276selection: new Range(0, 0, 0, 0),277filepath: '/workspace/file.ts',278});279const rendered = await element.render();280expect(rendered).toBeUndefined();281});282283test('single round produces AssistantMessage then ToolMessage', async () => {284const doc = makeDocument('const x = 1;');285const element = new ToolCallRoundsElement({286previousRounds: [makeRound(['call-1', 'result-one'])],287hasFailedEdits: false,288data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,289documentVersionAtRequest: doc.document.version,290isLargeFile: false,291selection: new Range(0, 0, 0, 0),292filepath: '/workspace/file.ts',293});294const output = JSON.stringify(await element.render());295// tool call id and result text both appear296expect(output).toContain('call-1');297expect(output).toContain('result-one');298});299300test('hasFailedEdits: false - no feedback tag', async () => {301const doc = makeDocument('const x = 1;');302const element = new ToolCallRoundsElement({303previousRounds: [makeRound(['call-1', 'ok'])],304hasFailedEdits: false,305data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,306documentVersionAtRequest: doc.document.version,307isLargeFile: false,308selection: new Range(0, 0, 0, 0),309filepath: '/workspace/file.ts',310});311const output = JSON.stringify(await element.render());312expect(output).not.toContain('feedback');313});314315test('hasFailedEdits: true + document unchanged - feedback without file content', async () => {316const doc = makeDocument('const x = 1;');317const element = new ToolCallRoundsElement({318previousRounds: [makeRound(['call-1', 'error'])],319hasFailedEdits: true,320data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,321documentVersionAtRequest: doc.document.version, // same version = no change322isLargeFile: false,323selection: new Range(0, 0, 0, 0),324filepath: '/workspace/file.ts',325});326const output = JSON.stringify(await element.render());327expect(output).toContain('feedback');328expect(output).toContain('No changes were made');329// should NOT include the file content block330expect(output).not.toContain('current file content');331});332333test('hasFailedEdits: true + document changed + small file - feedback with full file content', async () => {334const doc = makeDocument('const x = 1;');335setDocText(doc, 'const x = 2;'); // bumps version336const element = new ToolCallRoundsElement({337previousRounds: [makeRound(['call-1', 'error'])],338hasFailedEdits: true,339data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,340documentVersionAtRequest: doc.document.version - 1, // old version341isLargeFile: false,342selection: new Range(0, 0, 0, 0),343filepath: '/workspace/file.ts',344});345const output = JSON.stringify(await element.render());346expect(output).toContain('feedback');347expect(output).toContain('current file content');348expect(output).toContain('const x = 2;');349});350351test('hasFailedEdits: true + document changed + large file - feedback uses CroppedFileContentElement', async () => {352// Build a document that exceeds the large-file threshold353const lines = Array.from({ length: LARGE_FILE_LINE_THRESHOLD + 10 }, (_, i) => `let line${i} = ${i};`);354const content = lines.join('\n');355const doc = makeDocument(content);356setDocText(doc, content + '\n// changed');357const selection = new Range(0, 0, 0, 0);358const element = new ToolCallRoundsElement({359previousRounds: [makeRound(['call-1', 'error'])],360hasFailedEdits: true,361data: { document: doc.document, selection } as any,362documentVersionAtRequest: doc.document.version - 1,363isLargeFile: true,364selection,365filepath: '/workspace/file.ts',366});367const output = JSON.stringify(await element.render());368expect(output).toContain('feedback');369expect(output).toContain('current file content');370// CroppedFileContentElement receives 'filepath' as a plain string prop (distinguishes it371// from the small-file path which uses CodeBlock with a 'code' prop instead)372expect(output).toContain('"filepath":"/workspace/file.ts"');373});374375test('multiple rounds - content appears in round order', async () => {376const doc = makeDocument('const x = 1;');377const element = new ToolCallRoundsElement({378previousRounds: [379makeRound(['round1-call', 'round1-result']),380makeRound(['round2-call', 'round2-result']),381],382hasFailedEdits: false,383data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,384documentVersionAtRequest: doc.document.version,385isLargeFile: false,386selection: new Range(0, 0, 0, 0),387filepath: '/workspace/file.ts',388});389const output = JSON.stringify(await element.render());390const idx1 = output.indexOf('round1-call');391const idx2 = output.indexOf('round2-call');392expect(idx1).toBeGreaterThan(-1);393expect(idx2).toBeGreaterThan(-1);394expect(idx1).toBeLessThan(idx2);395});396397test('multiple rounds - results are interleaved with calls (result-1 before call-2)', async () => {398const doc = makeDocument('const x = 1;');399const element = new ToolCallRoundsElement({400previousRounds: [401makeRound(['round1-call', 'round1-result']),402makeRound(['round2-call', 'round2-result']),403],404hasFailedEdits: false,405data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,406documentVersionAtRequest: doc.document.version,407isLargeFile: false,408selection: new Range(0, 0, 0, 0),409filepath: '/workspace/file.ts',410});411const output = JSON.stringify(await element.render());412// The interleaving invariant: round1 call → round1 result → round2 call → round2 result413const idxCall1 = output.indexOf('round1-call');414const idxResult1 = output.indexOf('round1-result');415const idxCall2 = output.indexOf('round2-call');416const idxResult2 = output.indexOf('round2-result');417expect(idxCall1).toBeGreaterThan(-1);418expect(idxResult1).toBeGreaterThan(-1);419expect(idxCall2).toBeGreaterThan(-1);420expect(idxResult2).toBeGreaterThan(-1);421// call comes before its own result422expect(idxCall1).toBeLessThan(idxResult1);423// round 1's result comes before round 2's call (not batched)424expect(idxResult1).toBeLessThan(idxCall2);425// round 2's call comes before its own result426expect(idxCall2).toBeLessThan(idxResult2);427});428429test('multiple calls in one round - all calls precede their results', async () => {430const doc = makeDocument('const x = 1;');431const round: ICompletedToolCallRound = {432calls: [433[makeToolCall('read-call', 'read_file'), makeToolResult('file contents')],434[makeToolCall('edit-call', 'replace_string_in_file'), makeToolResult('edit result')],435]436};437const element = new ToolCallRoundsElement({438previousRounds: [round],439hasFailedEdits: false,440data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,441documentVersionAtRequest: doc.document.version,442isLargeFile: false,443selection: new Range(0, 0, 0, 0),444filepath: '/workspace/file.ts',445});446const output = JSON.stringify(await element.render());447// Both call ids appear448expect(output).toContain('read-call');449expect(output).toContain('edit-call');450// Both results appear451expect(output).toContain('file contents');452expect(output).toContain('edit result');453});454});455456457