Path: blob/main/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts
13399 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 { decodeHex, encodeHex, VSBuffer } from '../../../../base/common/buffer.js';6import { basename } from '../../../../base/common/path.js';7import { URI } from '../../../../base/common/uri.js';8import { IFileService } from '../../../files/common/files.js';9import { ILogService } from '../../../log/common/log.js';10import { IDiffComputeService } from '../../common/diffComputeService.js';11import { ISessionDatabase } from '../../common/sessionDataService.js';12import { FileEditKind, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js';1314const SESSION_DB_SCHEME = 'session-db';1516/**17* Builds a `session-db:` URI that references a file-edit content blob18* stored in the session database. Parsed by {@link parseSessionDbUri}.19*/20export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePath: string, part: 'before' | 'after'): string {21return URI.from({22scheme: SESSION_DB_SCHEME,23authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(),24path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}/${basename(filePath)}`,25}).toString();26}2728/** Parsed fields from a `session-db:` content URI. */29export interface ISessionDbUriFields {30sessionUri: string;31toolCallId: string;32filePath: string;33part: 'before' | 'after';34}3536/**37* Parses a `session-db:` URI produced by {@link buildSessionDbUri}.38* Returns `undefined` if the URI is not a valid `session-db:` URI.39*/40export function parseSessionDbUri(raw: string): ISessionDbUriFields | undefined {41const parsed = URI.parse(raw);42if (parsed.scheme !== SESSION_DB_SCHEME) {43return undefined;44}45const [, toolCallId, filePath, part] = parsed.path.split('/');46if (!toolCallId || !filePath || (part !== 'before' && part !== 'after')) {47return undefined;48}49try {50return {51sessionUri: decodeHex(parsed.authority).toString(),52toolCallId: decodeURIComponent(toolCallId),53filePath: decodeHex(filePath).toString(),54part55};56} catch {57return undefined;58}59}6061/**62* Tracks file edits made by tools in a session by snapshotting file content63* before and after each edit tool invocation, persisting snapshots into the64* session database.65*/66export class FileEditTracker {6768/**69* Pending edits keyed by file path. The `onPreToolUse` hook stores70* entries here; `completeEdit` pops them when the tool finishes.71*/72private readonly _pendingEdits = new Map<string, { beforeContent: VSBuffer; snapshotDone: Promise<void> }>();7374/**75* Completed edits keyed by file path. The `onPostToolUse` hook stores76* entries here; `takeCompletedEdit` retrieves them from the77* `onToolComplete` handler and persists to the database.78*/79private readonly _completedEdits = new Map<string, { beforeContent: VSBuffer; afterContent: VSBuffer }>();8081constructor(82private readonly _sessionUri: string,83private readonly _db: ISessionDatabase,84@IFileService private readonly _fileService: IFileService,85@ILogService private readonly _logService: ILogService,86@IDiffComputeService private readonly _diffComputeService: IDiffComputeService,87) { }8889/**90* Call from the `onPreToolUse` hook before an edit tool runs.91* Reads the file's current content into memory as the "before" state.92* The hook blocks the SDK until this returns, ensuring the snapshot93* captures pre-edit content.94*95* @param filePath - Absolute path of the file being edited.96*/97async trackEditStart(filePath: string): Promise<void> {98const snapshotDone = this._readFile(filePath);99const entry = { beforeContent: VSBuffer.fromString(''), snapshotDone: snapshotDone.then(buf => { entry.beforeContent = buf; }) };100this._pendingEdits.set(filePath, entry);101await entry.snapshotDone;102}103104/**105* Call from the `onPostToolUse` hook after an edit tool finishes.106* Reads the file content again as the "after" state and stores the107* result for later retrieval via {@link takeCompletedEdit}.108*109* @param filePath - Absolute path of the file that was edited.110*/111async completeEdit(filePath: string): Promise<void> {112const pending = this._pendingEdits.get(filePath);113if (!pending) {114return;115}116this._pendingEdits.delete(filePath);117await pending.snapshotDone;118119const afterContent = await this._readFile(filePath);120121this._completedEdits.set(filePath, {122beforeContent: pending.beforeContent,123afterContent,124});125}126127/**128* Retrieves and removes a completed edit for the given file path,129* persists it to the session database with computed diff counts,130* and returns the result as an {@link ToolResultFileEditContent}131* for inclusion in the tool result.132*133* @param toolCallId - The tool call that produced this edit.134* @param filePath - Absolute path of the edited file.135*/136async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): Promise<ToolResultFileEditContent | undefined> {137const edit = this._completedEdits.get(filePath);138if (!edit) {139return undefined;140}141this._completedEdits.delete(filePath);142143const beforeBytes = edit.beforeContent.buffer;144const afterBytes = edit.afterContent.buffer;145const beforeText = edit.beforeContent.toString();146const afterText = edit.afterContent.toString();147148let addedLines: number | undefined;149let removedLines: number | undefined;150try {151const counts = await this._diffComputeService.computeDiffCounts(beforeText, afterText);152addedLines = counts.added;153removedLines = counts.removed;154} catch (err) {155this._logService.warn(`[FileEditTracker] Failed to compute diff counts: ${filePath}`, err);156}157158try {159await this._db.storeFileEdit({160turnId,161toolCallId,162filePath,163kind: FileEditKind.Edit,164beforeContent: beforeBytes,165afterContent: afterBytes,166addedLines,167removedLines,168});169} catch (err) {170this._logService.warn(`[FileEditTracker] Failed to persist file edit to database: ${filePath}`, err);171}172173return {174type: ToolResultContentType.FileEdit,175before: {176uri: URI.file(filePath).toString(),177content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before') },178},179after: {180uri: URI.file(filePath).toString(),181content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after') },182},183diff: addedLines !== undefined ? { added: addedLines, removed: removedLines } : undefined,184};185}186187private async _readFile(filePath: string): Promise<VSBuffer> {188try {189const content = await this._fileService.readFile(URI.file(filePath));190return content.value;191} catch (err) {192this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err);193return VSBuffer.fromString('');194}195}196}197198199