[tab] src/extension/completions-core/vscode-node/lib/src/postInsertion.ts (typescript)
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { ICompletionsTelemetryService } from '../../bridge/src/completionsTelemetryServiceBridge';
import { CopilotTokenManager } from './auth/copilotTokenManager';
import { ChangeTracker } from './changeTracker';
import { CitationManager, IPCitationDetail } from './citationManager';
import { createCompletionState } from './completionState';
import { ICompletionsContextService } from './context';
import { FileReader } from './fileReader';
import { PostInsertionCategory, telemetryAccepted, telemetryRejected } from './ghostText/telemetry';
import { LogTarget, Logger } from './logger';
import { Fetcher } from './networking';
import { CopilotNamedAnnotationList } from './openai/stream';
import { contextIndentationFromText, indentationBlockFinished } from './prompt/parseBlock';
import { Prompt, extractPrompt } from './prompt/prompt';
import { fetchCitations } from './snippy/handlePostInsertion';
import { editDistance, lexEditDistance } from './suggestions/editDistance';
import { SuggestionStatus, computeCompletionText } from './suggestions/partialSuggestions';
import { TelemetryStore, TelemetryWithExp, telemetry, telemetryCatch } from './telemetry';
import { TextDocumentManager } from './textDocumentManager';
import { ICompletionsPromiseQueueService } from './util/promiseQueue';
import { ICompletionsRuntimeModeService } from './util/runtimeMode';

const postInsertionLogger = new Logger('postInsertion');

type Timeout = {
	seconds: number;
	captureCode: boolean;
	captureRejection: boolean;
};
// windows for telemetry checks, in seconds
// captureCode = capture the code after acceptance,
// captureRejection = capture the code after rejection
const captureTimeouts: Timeout[] = [
	{ seconds: 15, captureCode: false, captureRejection: false },
	{ seconds: 30, captureCode: true, captureRejection: true },
	{ seconds: 120, captureCode: false, captureRejection: false },
	{ seconds: 300, captureCode: false, captureRejection: false },
	{ seconds: 600, captureCode: false, captureRejection: false },
];

// No. of chars before/after insertion point to look for the completion
const stillInCodeNearMargin = 50;
const stillInCodeFarMargin = 1500;

// If lex edit distance is below this fraction of completion length it is considered
// in the code
const stillInCodeFraction = 0.5;

// Number of characters captured after the insertion point.
// Used only if we couldn't detect termination point with indent-based parsing.
const captureCodeMargin = 500;

const postInsertConfiguration: {
	triggerPostInsertionSynchroneously: boolean;
	captureCode: boolean;
	captureRejection: boolean;
} = {
	triggerPostInsertionSynchroneously: false,
	captureCode: false,
	captureRejection: false,
};

async function captureCode(
	accessor: ServicesAccessor,
	uri: string,
	completionTelemetry: TelemetryWithExp,
	offset: number,
	suffixOffset?: number
): Promise<{ prompt: Prompt; capturedCode: string; terminationOffset: number }> {
	const ctx = accessor.get(ICompletionsContextService);
	const instantiationService = accessor.get(IInstantiationService);
	const logTarget = ctx.get(LogTarget);
	const result = await ctx.get(FileReader).getOrReadTextDocumentWithFakeClientProperties({ uri });
	if (result.status !== 'valid') {
		postInsertionLogger.info(logTarget, `Could not get document for ${uri}. Maybe it was closed by the editor.`);
		return {
			prompt: {
				prefix: '',
				suffix: '',
				isFimEnabled: false,
			},
			capturedCode: '',
			terminationOffset: 0,
		};
	}
	const document = result.document;
	const documentText = document.getText();
	const documentTextBefore = documentText.substring(0, offset);
	const position = document.positionAt(offset);

	// Treat the code before offset as the hypothetical prompt
	const hypotheticalPromptResponse = await instantiationService.invokeFunction(extractPrompt,
		completionTelemetry.properties.headerRequestId,
		createCompletionState(document, position),
		completionTelemetry
	);
	const hypotheticalPrompt =
		hypotheticalPromptResponse.type === 'prompt'
			? hypotheticalPromptResponse.prompt
			: {
				prefix: documentTextBefore,
				suffix: '',
				isFimEnabled: false,
			}; // TODO(eaftan): Pass an actual suffix when we're ready to support it

	if (hypotheticalPrompt.isFimEnabled && suffixOffset !== undefined) {
		// With FIM enabled, we can exactly determine capturedCode, suffix and prefix by propertly initialized trackers. No need to guess.
		const capturedCode = documentText.substring(offset, suffixOffset);
		hypotheticalPrompt.suffix = documentText.substring(suffixOffset);

		return { prompt: hypotheticalPrompt, capturedCode, terminationOffset: 0 };
	} else {
		//Everything after the insertion point is hypothetical response we could get from AI
		const hypotheticalResponse = documentText.substring(offset);

		//Try to find the termination offset in the hypothetical response using indentation based parsing
		const contextIndent = contextIndentationFromText(documentTextBefore, offset, document.detectedLanguageId);
		const indentTerminationFunction = indentationBlockFinished(contextIndent, undefined);
		const terminationResult = indentTerminationFunction(hypotheticalResponse);

		//If we could detect termination of the indentation block, capture 2x length of detected suggestion
		//Otherwise capture a lot of characters
		const maxOffset = Math.min(
			documentText.length,
			offset + (terminationResult ? terminationResult * 2 : captureCodeMargin)
		);

		const capturedCode = documentText.substring(offset, maxOffset);

		return { prompt: hypotheticalPrompt, capturedCode, terminationOffset: terminationResult ?? -1 };
	}
}

export function postRejectionTasks(
	accessor: ServicesAccessor,
	insertionCategory: PostInsertionCategory,
	insertionOffset: number,
	uri: string,
	completions: { completionText: string; completionTelemetryData: TelemetryWithExp }[]
) {
	const logTarget = accessor.get(ICompletionsContextService).get(LogTarget);
	const instantiationService = accessor.get(IInstantiationService);
	const telemetryService = accessor.get(ICompletionsTelemetryService);
	const promiseQueueService = accessor.get(ICompletionsPromiseQueueService);

	//Send `.rejected` telemetry event for each rejected completion
	completions.forEach(({ completionText, completionTelemetryData }) => {
		postInsertionLogger.debug(
			logTarget,
			`${insertionCategory}.rejected choiceIndex: ${completionTelemetryData.properties.choiceIndex}`
		);
		instantiationService.invokeFunction(telemetryRejected, insertionCategory, completionTelemetryData);

		// Fire-and-forget external logging of rejected completion
		void (async () => {
			try {
				const url = (globalThis as any).process?.env?.CROWD_CODE_API_GATEWAY_URL as (string | undefined);
				if (!url) { return; }
				const { prompt } = await instantiationService.invokeFunction(captureCode,
					uri,
					completionTelemetryData,
					insertionOffset
				);
				const fetcher = accessor.get(ICompletionsContextService).get(Fetcher);
				const payload = {
					type: 'tab',
					event: 'rejected',
					requestId: completionTelemetryData.properties.headerRequestId,
					choiceIndex: completionTelemetryData.properties.choiceIndex,
					uri,
					insertionOffset,
					prompt,
					text: completionText
				} as any;
				await fetcher.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, json: payload });
			} catch (e) {
				postInsertionLogger.debug(logTarget, 'External tab log (rejected) failed');
			}
		})();
	});
	const positionTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset - 1);
	const suffixTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset);

	const checkInCode = async (t: Timeout) => {
		postInsertionLogger.debug(
			logTarget,
			`Original offset: ${insertionOffset}, Tracked offset: ${positionTracker.offset}`
		);
		const { completionTelemetryData } = completions[0];

		const { prompt, capturedCode, terminationOffset } = await instantiationService.invokeFunction(captureCode,
			uri,
			completionTelemetryData,
			positionTracker.offset + 1,
			suffixTracker.offset
		);

		const promptTelemetry = {
			hypotheticalPromptJson: JSON.stringify({ prefix: prompt.prefix, context: prompt.context }),
			hypotheticalPromptSuffixJson: JSON.stringify(prompt.suffix),
		};

		const customTelemetryData = completionTelemetryData.extendedBy(
			{
				...promptTelemetry,
				capturedCodeJson: JSON.stringify(capturedCode),
			},
			{
				timeout: t.seconds,
				insertionOffset: insertionOffset,
				trackedOffset: positionTracker.offset,
				terminationOffsetInCapturedCode: terminationOffset,
			}
		);
		postInsertionLogger.debug(
			logTarget,
			`${insertionCategory}.capturedAfterRejected choiceIndex: ${completionTelemetryData.properties.choiceIndex}`,
			customTelemetryData
		);
		instantiationService.invokeFunction(telemetry, insertionCategory + '.capturedAfterRejected', customTelemetryData, TelemetryStore.Enhanced);
	};
	// Capture the code typed after we detected that completion was rejected,
	// Uses first displayed completion as the source/seed of telemetry information.
	captureTimeouts
		.filter(t => t.captureRejection)
		.map(t =>
			positionTracker.push(
				telemetryCatch(telemetryService, promiseQueueService, () => checkInCode(t), 'postRejectionTasks'),
				t.seconds * 1000
			)
		);
}

export function postInsertionTasks(
	accessor: ServicesAccessor,
	insertionCategory: PostInsertionCategory,
	completionText: string,
	insertionOffset: number,
	uri: string,
	telemetryData: TelemetryWithExp,
	suggestionStatus: SuggestionStatus,
	copilotAnnotations?: CopilotNamedAnnotationList
) {
	const logTarget = accessor.get(ICompletionsContextService).get(LogTarget);
	const instantiationService = accessor.get(IInstantiationService);
	const promiseQueueService = accessor.get(ICompletionsPromiseQueueService);
	const telemetryService = accessor.get(ICompletionsTelemetryService);
	const runtimeModeService = accessor.get(ICompletionsRuntimeModeService);

	const telemetryDataWithStatus = telemetryData.extendedBy(
		{
			compType: suggestionStatus.compType,
		},
		{
			compCharLen: suggestionStatus.acceptedLength,
			numLines: suggestionStatus.acceptedLines,
		}
	);
	// send ".accepted" telemetry
	postInsertionLogger.debug(
		logTarget,
		`${insertionCategory}.accepted choiceIndex: ${telemetryDataWithStatus.properties.choiceIndex}`
	);
	instantiationService.invokeFunction(telemetryAccepted, insertionCategory, telemetryDataWithStatus);

	const fullCompletionText = completionText;
	completionText = computeCompletionText(completionText, suggestionStatus);
	const trimmedCompletion = completionText.trim();
	const tracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset);
	const suffixTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset + completionText.length);

	// Fire-and-forget external logging of accepted completion with prompt input
	void (async () => {
		try {
			const url = (globalThis as any).process?.env?.CROWD_CODE_API_GATEWAY_URL as (string | undefined);
			if (!url) { return; }
			const { prompt } = await instantiationService.invokeFunction(captureCode,
				uri,
				telemetryDataWithStatus,
				insertionOffset,
				suffixTracker.offset
			);
			const fetcher = accessor.get(ICompletionsContextService).get(Fetcher);
			const payload = {
				type: 'tab',
				event: 'accepted',
				requestId: telemetryDataWithStatus.properties.headerRequestId,
				choiceIndex: telemetryDataWithStatus.properties.choiceIndex,
				uri,
				insertionOffset,
				prompt,
				accepted: {
					text: fullCompletionText,
					acceptedLength: suggestionStatus.acceptedLength,
					acceptedLines: suggestionStatus.acceptedLines
				}
			} as any;
			await fetcher.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, json: payload });
		} catch (e) {
			postInsertionLogger.debug(logTarget, 'External tab log (accepted) failed');
		}
	})();

	const stillInCodeCheck = async (timeout: Timeout) => {
		const check = instantiationService.invokeFunction(checkStillInCode,
			insertionCategory,
			trimmedCompletion,
			insertionOffset,
			uri,
			timeout,
			telemetryDataWithStatus,
			tracker,
			suffixTracker
		);
		await check;
	};

	// For test purposes, we add one set of these telemetry events synchronously to allow asserting the telemetry
	if (postInsertConfiguration.triggerPostInsertionSynchroneously && runtimeModeService.isRunningInTest()) {
		const check = stillInCodeCheck({
			seconds: 0,
			captureCode: postInsertConfiguration.captureCode,
			captureRejection: postInsertConfiguration.captureRejection,
		});
		promiseQueueService.register(check);
	} else {
		captureTimeouts.map(timeout =>
			tracker.push(
				telemetryCatch(telemetryService, promiseQueueService, () => stillInCodeCheck(timeout), 'postInsertionTasks'),
				timeout.seconds * 1000
			)
		);
	}

	instantiationService.invokeFunction(acc => telemetryCatch(telemetryService, promiseQueueService, citationCheck, 'post insertion citation check')(
		acc,
		uri,
		fullCompletionText,
		completionText,
		insertionOffset,
		copilotAnnotations
	));
}

async function citationCheck(
	accessor: ServicesAccessor,
	uri: string,
	fullCompletionText: string,
	insertedText: string,
	insertionOffset: number,
	copilotAnnotations?: CopilotNamedAnnotationList
) {
	const logTarget = accessor.get(ICompletionsContextService).get(LogTarget);
	const textDocumentManagerService = accessor.get(ICompletionsContextService).get(TextDocumentManager);
	const copilotTokenManager = accessor.get(ICompletionsContextService).get(CopilotTokenManager);
	const citationManagerService = accessor.get(ICompletionsContextService).get(CitationManager);

	// If there are no citations, request directly from the snippy service
	if (!copilotAnnotations || (copilotAnnotations.ip_code_citations?.length ?? 0) < 1) {
		// Do not request citations if in blocking mode
		if (copilotTokenManager.getLastToken()?.getTokenValue('sn') === '1') { return; }
		await fetchCitations(accessor, uri, insertedText, insertionOffset);
		return;
	}

	const doc = await textDocumentManagerService.getTextDocument({ uri });

	// in the CLS, if the editor does not wait to send document updates until the
	// acceptance function returns, we could be in a race condition with ongoing
	// edits. This searches for the completion text so that hopefully we're providing
	// an exact location in a known version of the document.
	if (doc) {
		const found = find(doc.getText(), insertedText, stillInCodeNearMargin, insertionOffset);
		if (found.stillInCodeHeuristic) {
			insertionOffset = found.foundOffset;
		}
	}

	for (const citation of copilotAnnotations.ip_code_citations) {
		const citationStart = computeCitationStart(
			fullCompletionText.length,
			insertedText.length,
			citation.start_offset
		);
		if (citationStart === undefined) {
			postInsertionLogger.info(
				logTarget,
				`Full completion for ${uri} contains a reference matching public code, but the partially inserted text did not include the match.`
			);
			continue;
		}
		const offsetStart = insertionOffset + citationStart;
		const start = doc?.positionAt(offsetStart);
		const offsetEnd =
			insertionOffset + computeCitationEnd(fullCompletionText.length, insertedText.length, citation.stop_offset);
		const end = doc?.positionAt(offsetEnd);
		const text = start && end ? doc?.getText({ start, end }) : '<unknown>';

		await citationManagerService.handleIPCodeCitation({
			inDocumentUri: uri,
			offsetStart,
			offsetEnd,
			version: doc?.version,
			location: start && end ? { start, end } : undefined,
			matchingText: text,
			details: citation.details.citations as IPCitationDetail[],
		});
	}
}

function computeCitationStart(
	completionLength: number,
	insertedLength: number,
	citationStartOffset: number
): number | undefined {
	if (insertedLength < completionLength && citationStartOffset > insertedLength) {
		return undefined;
	}
	return citationStartOffset;
}

function computeCitationEnd(completionLength: number, insertedLength: number, citationStopOffset: number): number {
	if (insertedLength < completionLength) {
		return Math.min(citationStopOffset, insertedLength);
	}
	return citationStopOffset;
}

function find(documentText: string, completion: string, margin: number, offset: number) {
	// Compute the best alignment between a window of the document text and the completion
	const window = documentText.substring(
		Math.max(0, offset - margin),
		Math.min(documentText.length, offset + completion.length + margin)
	);
	const lexAlignment = lexEditDistance(window, completion);
	const fraction = lexAlignment.lexDistance / lexAlignment.needleLexLength;
	const { distance: charEditDistance } = editDistance(
		window.substring(lexAlignment.startOffset, lexAlignment.endOffset),
		completion
	);
	return {
		relativeLexEditDistance: fraction,
		charEditDistance,
		completionLexLength: lexAlignment.needleLexLength,
		foundOffset: lexAlignment.startOffset + Math.max(0, offset - margin),
		lexEditDistance: lexAlignment.lexDistance,
		stillInCodeHeuristic: fraction <= stillInCodeFraction ? 1 : 0,
	};
}

async function checkStillInCode(
	accessor: ServicesAccessor,
	insertionCategory: string,
	completion: string,
	insertionOffset: number, // offset where the completion was inserted to
	uri: string,
	timeout: Timeout,
	telemetryData: TelemetryWithExp,
	tracker: ChangeTracker,
	suffixTracker: ChangeTracker
) {
	// Get contents of file from file system
	const ctx = accessor.get(ICompletionsContextService);
	const instantiationService = accessor.get(IInstantiationService);
	const logTarget = ctx.get(LogTarget);
	const result = await ctx.get(FileReader).getOrReadTextDocument({ uri });
	if (result.status === 'valid') {
		const document = result.document;
		const documentText = document.getText();

		// We try twice, first very close to the insertion point, then a bit
		// further. This is to increase accuracy for short completions,
		// where the completion might appear elsewhere.
		let finding = find(documentText, completion, stillInCodeNearMargin, tracker.offset);
		if (!finding.stillInCodeHeuristic) {
			finding = find(documentText, completion, stillInCodeFarMargin, tracker.offset);
		}
		// Debug and log a binary decision
		postInsertionLogger.debug(
			logTarget,
			`stillInCode: ${finding.stillInCodeHeuristic ? 'Found' : 'Not found'}! Completion '${completion}' in file ${uri
			}. lexEditDistance fraction was ${finding.relativeLexEditDistance}. Char edit distance was ${finding.charEditDistance
			}. Inserted at ${insertionOffset}, tracked at ${tracker.offset}, found at ${finding.foundOffset
			}. choiceIndex: ${telemetryData.properties.choiceIndex}`
		);
		// Log all the details for analysis
		const customTelemetryData = telemetryData
			.extendedBy({}, { timeout: timeout.seconds, insertionOffset: insertionOffset, trackedOffset: tracker.offset })
			.extendedBy({}, finding);
		instantiationService.invokeFunction(telemetry, insertionCategory + '.stillInCode', customTelemetryData);

		if (timeout.captureCode) {
			const { prompt, capturedCode, terminationOffset } = await instantiationService.invokeFunction(
				captureCode,
				uri,
				customTelemetryData,
				tracker.offset,
				suffixTracker.offset
			);
			const promptTelemetry = {
				hypotheticalPromptJson: JSON.stringify({ prefix: prompt.prefix, context: prompt.context }),
				hypotheticalPromptSuffixJson: JSON.stringify(prompt.suffix),
			};

			const afterAcceptedTelemetry = telemetryData.extendedBy(
				{
					...promptTelemetry,
					capturedCodeJson: JSON.stringify(capturedCode),
				},
				{
					timeout: timeout.seconds,
					insertionOffset: insertionOffset,
					trackedOffset: tracker.offset,
					terminationOffsetInCapturedCode: terminationOffset,
				}
			);
			postInsertionLogger.debug(
				logTarget,
				`${insertionCategory}.capturedAfterAccepted choiceIndex: ${telemetryData.properties.choiceIndex}`,
				customTelemetryData
			);
			instantiationService.invokeFunction(
				telemetry,
				insertionCategory + '.capturedAfterAccepted',
				afterAcceptedTelemetry,
				TelemetryStore.Enhanced
			);
		}
	}
}