From 2f5736d70f7d7094a6771690cd250bf747f1f859 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:19:18 -0700 Subject: [PATCH 01/21] Track Agent Host edit sources Attribute Agent Host file edits in a workbench-side synthetic tracker and emit scoped long-term edit source details after disk reconciliation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/editor/common/textModelEditSource.ts | 6 + .../telemetry/agentHostEditSourceTracking.ts | 455 ++++++++++++++++++ .../browser/telemetry/editSourceTelemetry.ts | 59 +++ .../telemetry/editSourceTrackingFeature.ts | 2 + .../telemetry/editSourceTrackingImpl.ts | 51 +- .../browser/telemetry/editTracker.ts | 9 + .../agentHostEditSourceTracking.test.ts | 106 ++++ 7 files changed, 642 insertions(+), 46 deletions(-) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index d0ba71d2c355a5..3973745175c5c2 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -108,12 +108,18 @@ export const EditSources = { mode: string | undefined; extensionId: VersionedExtensionId | undefined; codeBlockSuggestionId: EditSuggestionId | undefined; + harness?: string; + origin?: string; + trackingScope?: string; }) { return createEditSource({ source: 'Chat.applyEdits', $modelId: avoidPathRedaction(data.modelId), $extensionId: data.extensionId?.extensionId, $extensionVersion: data.extensionId?.version, + $harness: data.harness, + $origin: data.origin, + $trackingScope: data.trackingScope, $$languageId: data.languageId, $$sessionId: data.sessionId, $$requestId: data.requestId, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts new file mode 100644 index 00000000000000..89b46230263566 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -0,0 +1,455 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { extname } from '../../../../../base/common/path.js'; +import { autorun, derived, IObservable, IObservableWithChange, IReader, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/protocol/common/actions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ToolResultContentType, type ToolResultFileEditContent } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ISCMService } from '../../../scm/common/scm.js'; +import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; +import { IRandomService } from '../randomService.js'; +import { DocumentEditSourceTracker } from './editTracker.js'; +import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; + +const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; +const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; + +type ComputeDiff = (original: string, modified: string) => Promise; +type GetRepo = (resource: URI, reader: IReader) => ScmRepoAdapter | undefined; +type SendDetails = (data: IEditSourcesDetailsTelemetryData, forwardToGitHub: boolean) => void; + +/** + * An in-memory document stream containing only Agent Host edits and reconciliation edits. + */ +class AgentHostSyntheticDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialText: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialText)); + } + + get text(): string { + return this._value.get().value; + } + + async applyTransition(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { + if (this.text !== beforeText) { + await this._apply(this.text, beforeText, EditSources.reloadFromDisk(), computeDiff); + } + await this._apply(beforeText, afterText, source, computeDiff); + } + + async reconcile(text: string, computeDiff: ComputeDiff): Promise { + await this._apply(this.text, text, EditSources.reloadFromDisk(), computeDiff); + } + + private async _apply(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { + if (beforeText === afterText) { + return; + } + const data = new EditSourceData(source).toEditSourceData(); + const edit = (await computeDiff(beforeText, afterText)).mapData(() => data); + this._value.set(new StringText(afterText), undefined, { edit }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +/** + * Tracks long-term Agent Host AI attribution for one file. + */ +export class AgentHostTrackedFile extends Disposable { + private readonly _document: AgentHostSyntheticDocument; + private readonly _tracker = this._register(new MutableDisposable()); + private readonly _resource: ISettableObservable; + private readonly _repo; + private _languageId = 'plaintext'; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + resource: URI, + initialText: string, + private readonly _readCurrentText: (resource: URI) => Promise, + private readonly _computeDiff: ComputeDiff, + getRepo: GetRepo, + private readonly _generateUuid: () => string, + private readonly _sendDetails: SendDetails, + private readonly _logService: ILogService, + private readonly _onDidExpire: () => void, + ) { + super(); + this._resource = observableValue(this, resource); + this._document = this._register(new AgentHostSyntheticDocument(initialText)); + this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); + this._repo = derived(this, reader => getRepo(this._resource.read(reader), reader)); + + this._register(autorun(reader => { + const repo = this._repo.read(reader); + if (!repo) { + return; + } + reader.store.add(runOnChange(repo.headCommitHashObs, () => this._flushAndLog('hashChange'))); + reader.store.add(runOnChange(repo.headBranchNameObs, () => this._flushAndLog('branchChange'))); + })); + + this._register(new IntervalTimer()).cancelAndSet(() => this._expireAndLog(), 10 * 60 * 60 * 1000); + } + + get resource(): URI { + return this._resource.get(); + } + + setResource(resource: URI): void { + this._resource.set(resource, undefined); + } + + applyEdit(beforeText: string, afterText: string, source: TextModelEditSource, languageId: string): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + await this._document.applyTransition(beforeText, afterText, source, this._computeDiff); + if (!this._isDisposed) { + this._languageId = languageId; + } + }); + } + + flush(trigger: EditTelemetryTrigger): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + const currentText = await this._readCurrentText(this.resource); + if (currentText === undefined || this._isDisposed) { + return; + } + + await this._document.reconcile(currentText, this._computeDiff); + const tracker = this._tracker.value; + if (!tracker) { + return; + } + tracker.applyPendingExternalEdits(); + this._sendTelemetry(trigger, tracker); + this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); + }); + } + + private _sendTelemetry(trigger: EditTelemetryTrigger, tracker: DocumentEditSourceTracker): void { + const retainedByKey = new Map(); + let totalModifiedCount = 0; + for (const range of tracker.getTrackedRanges()) { + if (range.sourceRepresentative.props.$trackingScope !== AGENT_HOST_TRACKING_SCOPE) { + continue; + } + totalModifiedCount += range.range.length; + retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.range.length); + } + + const entries = tracker.getAllKeys() + .map(key => ({ key, representative: tracker.getRepresentative(key), modifiedCount: retainedByKey.get(key) ?? 0 })) + .filter(entry => entry.representative?.props.$trackingScope === AGENT_HOST_TRACKING_SCOPE) + .sort((a, b) => b.modifiedCount - a.modifiedCount) + .slice(0, 30); + if (entries.length === 0) { + return; + } + + const statsUuid = this._generateUuid(); + for (const entry of entries) { + const representative = entry.representative!; + sendEditSourcesDetailsTelemetryData( + this._sendDetails, + representative, + entry.key, + entry.modifiedCount, + tracker.getTotalInsertedCharactersCount(entry.key), + totalModifiedCount, + this._languageId, + statsUuid, + trigger, + ); + } + } + + private _enqueue(operation: () => Promise): Promise { + const result = this._operationQueue.then(operation, operation); + this._operationQueue = result.then(() => undefined, () => undefined); + return result; + } + + private _flushAndLog(trigger: EditTelemetryTrigger): void { + this.flush(trigger).catch(error => this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`)); + } + + private _expireAndLog(): void { + this.flush('10hours').then(() => this._onDidExpire(), error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`); + }); + } + + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } +} + +function sendEditSourcesDetailsTelemetryData( + sendDetails: SendDetails, + representative: TextModelEditSource, + sourceKey: string, + modifiedCount: number, + deltaModifiedCount: number, + totalModifiedCount: number, + languageId: string, + statsUuid: string, + trigger: EditTelemetryTrigger, +): void { + const harness = representative.props.$harness; + sendDetails({ + mode: 'longterm', + sourceKey, + sourceKeyCleaned: representative.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), + extensionId: representative.props.$extensionId, + extensionVersion: representative.props.$extensionVersion, + modelId: representative.props.$modelId, + trigger, + languageId, + statsUuid, + conversationId: representative.props.$$sessionId, + requestId: representative.props.$$requestId, + origin: representative.props.$origin, + harness, + trackingScope: representative.props.$trackingScope, + modifiedCount, + deltaModifiedCount, + totalModifiedCount, + }, harness === 'copilot-sdk'); +} + +/** + * Converts Agent Host file-edit actions into workbench edit-source telemetry. + */ +export class AgentHostEditSourceTracking extends Disposable { + private readonly _connectionListeners = this._register(new MutableDisposable()); + private readonly _trackedFiles = new Map(); + private readonly _diffService: DiffService; + private readonly _scmAdapter: ScmAdapter; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + private readonly _detailsEnabled: IObservable, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, + @IFileService private readonly _fileService: IFileService, + @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @ILanguageService private readonly _languageService: ILanguageService, + @ISCMService scmService: ISCMService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IRandomService private readonly _randomService: IRandomService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._diffService = new DiffService(editorWorkerService); + this._scmAdapter = new ScmAdapter(scmService); + this._syncConnectionListeners(); + this._register(this._connectionsService.onDidChangeConnections(() => this._syncConnectionListeners())); + this._register(autorun(reader => { + if (!this._detailsEnabled.read(reader)) { + this._clearTrackedFiles(); + } + })); + } + + private _syncConnectionListeners(): void { + const store = new DisposableStore(); + for (const connectionInfo of this._connectionsService.connections) { + const connection = connectionInfo.connection; + if (!connection) { + continue; + } + store.add(connection.onDidAction(envelope => { + const action = envelope.action; + if (!this._detailsEnabled.get() || action.type !== ActionType.ChatToolCallComplete || !isAhpChatChannel(envelope.channel.toString())) { + return; + } + this._enqueue(async () => { + if (!this._detailsEnabled.get()) { + return; + } + const session = URI.parse(parseRequiredSessionUriFromChatUri(envelope.channel)); + const provider = AgentSession.provider(session); + if (!provider) { + return; + } + for (const content of action.result.content ?? []) { + if (content.type === ToolResultContentType.FileEdit) { + await this._processFileEdit(connectionInfo.authority, session, provider, action.turnId, content); + } + } + }); + })); + } + this._connectionListeners.value = store; + } + + private async _processFileEdit( + connectionAuthority: string, + session: URI, + provider: string, + turnId: string, + fileEdit: ToolResultFileEditContent, + ): Promise { + const normalized = normalizeFileEdit(fileEdit); + if (!normalized) { + return; + } + + const resource = toAgentHostUri(normalized.resource, connectionAuthority); + if (extname(resource.path).toLowerCase() === '.ipynb') { + return; + } + + const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; + const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; + if (this._isDisposed || !this._detailsEnabled.get() || beforeText === undefined || afterText === undefined || Math.max(beforeText.length, afterText.length) > MAX_TRACKED_FILE_SIZE) { + return; + } + + const harness = provider === 'copilot' ? 'copilot-sdk' : provider; + const agentSessionId = AgentSession.id(session); + const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(resource, firstLine(afterText || beforeText)) ?? 'plaintext'; + const source = EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: agentSessionId, + requestId: turnId, + languageId, + mode: undefined, + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness, + origin: 'agentHost', + trackingScope: AGENT_HOST_TRACKING_SCOPE, + }); + + const resourceKey = this._uriIdentityService.extUri.getComparisonKey(resource); + const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; + const beforeResourceKey = beforeResource ? this._uriIdentityService.extUri.getComparisonKey(beforeResource) : undefined; + let trackedFile = this._trackedFiles.get(resourceKey); + if (!trackedFile && beforeResourceKey && beforeResourceKey !== resourceKey) { + trackedFile = this._trackedFiles.get(beforeResourceKey); + if (trackedFile) { + this._trackedFiles.delete(beforeResourceKey); + this._trackedFiles.set(resourceKey, trackedFile); + trackedFile.setResource(resource); + } + } + if (!trackedFile) { + const createdTrackedFile = new AgentHostTrackedFile( + resource, + beforeText, + currentResource => this._readCurrentText(currentResource), + (original, modified) => this._diffService.computeDiff(original, modified), + (repoResource, reader) => this._scmAdapter.getRepo(repoResource, reader), + () => this._randomService.generateUuid(), + (data, forwardToGitHub) => sendEditSourcesDetailsTelemetry(this._telemetryService, data, forwardToGitHub), + this._logService, + () => this._removeTrackedFile(createdTrackedFile), + ); + trackedFile = createdTrackedFile; + this._trackedFiles.set(resourceKey, trackedFile); + } + + await trackedFile.applyEdit(beforeText, afterText, source, languageId); + } + + private async _readSnapshot(resource: URI, connectionAuthority: string): Promise { + return this._readText(toAgentHostUri(resource, connectionAuthority), false); + } + + private async _readCurrentText(resource: URI): Promise { + return this._readText(resource, true); + } + + private async _readText(resource: URI, missingAsEmpty: boolean): Promise { + try { + const value = (await this._fileService.readFile(resource)).value.toString(); + if (value.includes('\0')) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping binary file ${resource.toString()}`); + return undefined; + } + return value; + } catch (error) { + if (missingAsEmpty && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return ''; + } + throw error; + } + } + + private _enqueue(operation: () => Promise): void { + const run = async () => { + if (!this._isDisposed) { + await operation(); + } + }; + const result = this._operationQueue.then(run, run); + this._operationQueue = result.catch(error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to process Agent Host edit: ${error}`); + }); + } + + private _removeTrackedFile(trackedFile: AgentHostTrackedFile): void { + for (const [key, value] of this._trackedFiles) { + if (value === trackedFile) { + this._trackedFiles.delete(key); + trackedFile.dispose(); + return; + } + } + } + + private _clearTrackedFiles(): void { + for (const trackedFile of this._trackedFiles.values()) { + trackedFile.dispose(); + } + this._trackedFiles.clear(); + } + + override dispose(): void { + this._isDisposed = true; + this._clearTrackedFiles(); + super.dispose(); + } +} + +function firstLine(text: string): string { + const lineBreak = text.search(/\r\n|\r|\n/); + return lineBreak === -1 ? text : text.substring(0, lineBreak); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts new file mode 100644 index 00000000000000..d41e047d36284c --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { forwardToChannelIf } from '../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; + +export type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; +export type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; + +export interface IEditSourcesDetailsTelemetryData { + mode: EditTelemetryMode; + sourceKey: string; + sourceKeyCleaned: string; + extensionId: string | undefined; + extensionVersion: string | undefined; + modelId: string | undefined; + trigger: EditTelemetryTrigger; + languageId: string; + statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; + origin: string | undefined; + harness: string | undefined; + trackingScope: string | undefined; + modifiedCount: number; + deltaModifiedCount: number; + totalModifiedCount: number; +} + +type EditSourcesDetailsTelemetryClassification = { + owner: 'hediet'; + comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; + mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; + sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; + sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; + modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; + origin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The system that observed and attributed the edit.' }; + harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent harness that produced the edit.' }; + trackingScope: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The set of edit sources represented by the row.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; + deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; + totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; +}; + +export function sendEditSourcesDetailsTelemetry(telemetryService: ITelemetryService, data: IEditSourcesDetailsTelemetryData, forwardToGitHub?: boolean): void { + telemetryService.publicLog2('editTelemetry.editSources.details', { + ...data, + ...(forwardToGitHub === undefined ? {} : forwardToChannelIf(forwardToGitHub)), + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index 2a7e05d78b8a84..82d53b7fe6dc9e 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -27,6 +27,7 @@ import { DataChannelForwardingTelemetryService } from '../../../../../platform/d import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from '../settings.js'; import { VSCodeWorkspace } from '../helpers/vscodeObservableWorkspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { AgentHostEditSourceTracking } from './agentHostEditSourceTracking.js'; export class EditTrackingFeature extends Disposable { @@ -69,6 +70,7 @@ export class EditTrackingFeature extends Disposable { [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments)); + this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails)); this._register(autorun((reader) => { if (!this._editSourceTrackingShowDecorations.read(reader)) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 55fb2fdf16f967..4eca926717bd68 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -17,9 +17,7 @@ import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; - -type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; -type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; +import { EditTelemetryMode, EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; export class EditSourceTrackingImpl extends Disposable { public readonly docsState; @@ -200,60 +198,21 @@ class TrackedDocumentInfo extends Disposable { const repr = t.getRepresentative(key)!; const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); - this._telemetryService.publicLog2<{ - mode: EditTelemetryMode; - sourceKey: string; - - sourceKeyCleaned: string; - extensionId: string | undefined; - extensionVersion: string | undefined; - modelId: string | undefined; - - trigger: EditTelemetryTrigger; - languageId: string; - statsUuid: string; - conversationId: string | undefined; - requestId: string | undefined; - modifiedCount: number; - deltaModifiedCount: number; - totalModifiedCount: number; - }, { - owner: 'hediet'; - comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; - - mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; - sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; - - sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; - extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; - extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; - modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; - - languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; - statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; - conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; - requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; - - trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; - - modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; - totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - - }>('editTelemetry.editSources.details', { + sendEditSourcesDetailsTelemetry(this._telemetryService, { mode, sourceKey: key, - sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), extensionId: repr.props.$extensionId, extensionVersion: repr.props.$extensionVersion, modelId: repr.props.$modelId, - trigger, languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, conversationId: repr.props.$$sessionId, requestId: repr.props.$$requestId, + origin: repr.props.$origin, + harness: repr.props.$harness, + trackingScope: repr.props.$trackingScope, modifiedCount: value, deltaModifiedCount: deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts index 59553b0d418fda..afffaa88706151 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts @@ -80,6 +80,15 @@ export class DocumentEditSourceTracker extends Disposable { return this._representativePerKey.get(key); } + public applyPendingExternalEdits(): void { + if (this._pendingExternalEdits.isEmpty()) { + return; + } + this._applyEdit(this._pendingExternalEdits); + this._pendingExternalEdits = AnnotatedStringEdit.empty; + this._update.trigger(undefined); + } + public getTrackedRanges(reader?: IReader): TrackedEdit[] { this._update.read(reader); const ranges = this._edits.getNewRanges(); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts new file mode 100644 index 00000000000000..d10e19e3cc5e1e --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AgentHostTrackedFile } from '../../browser/telemetry/agentHostEditSourceTracking.js'; +import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; + +suite('Agent Host Edit Source Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks AI edits separately from reconciliation edits', async () => { + const disposables = new DisposableStore(); + let currentText = ''; + let uuid = 0; + const sentTelemetry: { data: IEditSourcesDetailsTelemetryData; forwardToGitHub: boolean }[] = []; + const trackedFile = disposables.add(new AgentHostTrackedFile( + URI.file('C:\\repo\\file.ts'), + '', + async () => currentText, + async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + () => undefined, + () => `stats-${++uuid}`, + (data, forwardToGitHub) => sentTelemetry.push({ data, forwardToGitHub }), + new NullLogService(), + () => { }, + )); + + await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilot-sdk', 'session-1', 'turn-1'), 'typescript'); + await trackedFile.applyEdit('alpha\n', 'alpha\nbeta\n', agentHostEditSource('claude', 'session-1', 'turn-2'), 'typescript'); + currentText = 'alpha\nX\n'; + await trackedFile.flush('hashChange'); + await trackedFile.flush('hashChange'); + + assert.deepStrictEqual(sentTelemetry, [ + { + data: { + mode: 'longterm', + sourceKey: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + conversationId: 'session-1', + requestId: 'turn-1', + origin: 'agentHost', + harness: 'copilot-sdk', + trackingScope: 'agentHostAIOnly', + modifiedCount: 6, + deltaModifiedCount: 6, + totalModifiedCount: 7, + }, + forwardToGitHub: true, + }, + { + data: { + mode: 'longterm', + sourceKey: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + conversationId: 'session-1', + requestId: 'turn-2', + origin: 'agentHost', + harness: 'claude', + trackingScope: 'agentHostAIOnly', + modifiedCount: 1, + deltaModifiedCount: 5, + totalModifiedCount: 7, + }, + forwardToGitHub: false, + }, + ]); + + disposables.dispose(); + }); +}); + +function agentHostEditSource(harness: string, sessionId: string, turnId: string) { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId, + requestId: turnId, + languageId: 'typescript', + mode: undefined, + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness, + origin: 'agentHost', + trackingScope: 'agentHostAIOnly', + }); +} From c2cf4c5f3b5fba85889d3bf2904107e60a0531f1 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:15:08 -0700 Subject: [PATCH 02/21] Fix Agent Host edit telemetry forwarding Use the canonical copilotcli provider identifier for harness metadata and GitHub edit-telemetry forwarding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/telemetry/agentHostEditSourceTracking.ts | 4 ++-- .../test/browser/agentHostEditSourceTracking.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 89b46230263566..72c2acc652ac62 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -249,7 +249,7 @@ function sendEditSourcesDetailsTelemetryData( modifiedCount, deltaModifiedCount, totalModifiedCount, - }, harness === 'copilot-sdk'); + }, harness === 'copilotcli'); } /** @@ -342,7 +342,7 @@ export class AgentHostEditSourceTracking extends Disposable { return; } - const harness = provider === 'copilot' ? 'copilot-sdk' : provider; + const harness = provider; const agentSessionId = AgentSession.id(session); const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(resource, firstLine(afterText || beforeText)) ?? 'plaintext'; const source = EditSources.chatApplyEdits({ diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index d10e19e3cc5e1e..cf2ee29619c873 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -33,7 +33,7 @@ suite('Agent Host Edit Source Tracking', () => { () => { }, )); - await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilot-sdk', 'session-1', 'turn-1'), 'typescript'); + await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilotcli', 'session-1', 'turn-1'), 'typescript'); await trackedFile.applyEdit('alpha\n', 'alpha\nbeta\n', agentHostEditSource('claude', 'session-1', 'turn-2'), 'typescript'); currentText = 'alpha\nX\n'; await trackedFile.flush('hashChange'); @@ -43,8 +43,8 @@ suite('Agent Host Edit Source Tracking', () => { { data: { mode: 'longterm', - sourceKey: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', - sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', extensionId: undefined, extensionVersion: undefined, modelId: undefined, @@ -54,7 +54,7 @@ suite('Agent Host Edit Source Tracking', () => { conversationId: 'session-1', requestId: 'turn-1', origin: 'agentHost', - harness: 'copilot-sdk', + harness: 'copilotcli', trackingScope: 'agentHostAIOnly', modifiedCount: 6, deltaModifiedCount: 6, From 76c2dfb9ce2ffc34bb7a3451189327eea1ffcc72 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:47:26 -0700 Subject: [PATCH 03/21] Skip Agent Host attribution for dirty files Avoid recording disk-based Agent Host edit attribution when an open text model has unsaved changes, pending a product decision on dirty-document reconciliation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../telemetry/agentHostEditSourceTracking.ts | 16 ++++++++++++++++ .../agentHostEditSourceTracking.test.ts | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 72c2acc652ac62..307dab52a95565 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -12,6 +12,7 @@ import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/co import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -24,6 +25,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { ISCMService } from '../../../scm/common/scm.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; import { IRandomService } from '../randomService.js'; import { DocumentEditSourceTracker } from './editTracker.js'; @@ -268,7 +270,9 @@ export class AgentHostEditSourceTracking extends Disposable { @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IFileService private readonly _fileService: IFileService, @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @IModelService private readonly _modelService: IModelService, @ILanguageService private readonly _languageService: ILanguageService, + @ITextFileService private readonly _textFileService: ITextFileService, @ISCMService scmService: ISCMService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IRandomService private readonly _randomService: IRandomService, @@ -335,6 +339,14 @@ export class AgentHostEditSourceTracking extends Disposable { if (extname(resource.path).toLowerCase() === '.ipynb') { return; } + const editedResources = [normalized.beforeUri, normalized.afterUri] + .filter(resource => resource !== undefined) + .map(resource => toAgentHostUri(resource, connectionAuthority)); + const dirtyResource = editedResources.find(resource => isDirtyOpenTextModel(resource, this._modelService, this._textFileService)); + if (dirtyResource) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); + return; + } const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; @@ -453,3 +465,7 @@ function firstLine(text: string): string { const lineBreak = text.search(/\r\n|\r|\n/); return lineBreak === -1 ? text : text.substring(0, lineBreak); } + +export function isDirtyOpenTextModel(resource: URI, modelService: Pick, textFileService: Pick): boolean { + return modelService.getModel(resource) !== null && textFileService.isDirty(resource); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index cf2ee29619c873..b770256969d4e3 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -8,9 +8,10 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { AgentHostTrackedFile } from '../../browser/telemetry/agentHostEditSourceTracking.js'; +import { AgentHostTrackedFile, isDirtyOpenTextModel } from '../../browser/telemetry/agentHostEditSourceTracking.js'; import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; suite('Agent Host Edit Source Tracking', () => { @@ -88,6 +89,21 @@ suite('Agent Host Edit Source Tracking', () => { disposables.dispose(); }); + + test('only skips attribution for open dirty text models', () => { + const resource = URI.file('C:\\repo\\file.ts'); + const model = Object.create(null) as ITextModel; + + assert.deepStrictEqual({ + closedDirty: isDirtyOpenTextModel(resource, { getModel: () => null }, { isDirty: () => true }), + openClean: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => false }), + openDirty: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => true }), + }, { + closedDirty: false, + openClean: false, + openDirty: true, + }); + }); }); function agentHostEditSource(harness: string, sessionId: string, turnId: string) { From e29e864735d70ebb88c2939d4e572fe9c5de0bc8 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 15 Jul 2026 13:48:08 -0700 Subject: [PATCH 04/21] agentHost: unblock disconnected client tool calls Handle client-tool failures that arrive before Copilot registers its permission request, and suppress stale ready actions after terminal completion. Add unit and real Copilot replay coverage for the disconnect race. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/pendingRequestRegistry.ts | 5 ++ .../agentHost/node/agentSideEffects.ts | 8 +++ .../node/copilot/copilotAgentSession.ts | 9 +++ .../common/pendingRequestRegistry.test.ts | 9 +++ .../test/node/agentSideEffects.test.ts | 59 +++++++++++++++++++ .../test/node/copilotAgentSession.test.ts | 52 +++++++++++++++- ...e-permission-still-completes-the-turn.yaml | 36 +++++++++++ .../copilotAgentHostE2E.integrationTest.ts | 58 ++++++++++++++++++ 8 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml diff --git a/src/vs/platform/agentHost/common/pendingRequestRegistry.ts b/src/vs/platform/agentHost/common/pendingRequestRegistry.ts index e3bd5d4649b99a..d77d8400de6d87 100644 --- a/src/vs/platform/agentHost/common/pendingRequestRegistry.ts +++ b/src/vs/platform/agentHost/common/pendingRequestRegistry.ts @@ -91,6 +91,11 @@ export class PendingRequestRegistry { } } + /** Whether a result arrived before a request registered under `key`. */ + hasBufferedResult(key: string): boolean { + return this._earlyResults.has(key); + } + /** * Resolve every parked deferred with `denyValue` and clear the registry. * diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 54a23b3b9818bc..d16c4b54d80a63 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1010,6 +1010,14 @@ export class AgentSideEffects extends Disposable { const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + if (toolCall + && toolCall.status !== ToolCallStatus.Streaming + && toolCall.status !== ToolCallStatus.Running + && toolCall.status !== ToolCallStatus.PendingConfirmation) { + this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); + this._logService.trace(`[AgentSideEffects] Dropping stale tool ready for ${e.state.toolCallId}: status=${toolCall.status}`); + return; + } const contributor = e.state.contributor ?? toolCall?.contributor; let effective = e; const clientShouldAutoApprove = autoApproval !== undefined diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 97fa8201c01c34..047ba6d4c3f57b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1882,6 +1882,15 @@ export class CopilotAgentSession extends Disposable { const isShellRequest = request.kind === 'shell' || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); + if (request.kind === 'custom-tool' + && typeof request.toolName === 'string' + && this._clientToolNames.has(request.toolName) + && this._pendingClientToolCalls.hasBufferedResult(toolCallId) + ) { + this._logService.info(`[Copilot:${this.sessionId}] Auto-approving client tool ${request.toolName} because its result arrived before the permission request`); + return { kind: 'approve-once' }; + } + this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); const deferred = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts b/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts index dac9dbaadf5066..ac169ab78602bd 100644 --- a/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts +++ b/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts @@ -102,6 +102,15 @@ suite('PendingRequestRegistry', () => { assert.strictEqual(await promise, 'value'); }); + test('hasBufferedResult reports only unconsumed early results', async () => { + const registry = new PendingRequestRegistry(); + assert.strictEqual(registry.hasBufferedResult('k'), false); + registry.respondOrBuffer('k', 'early'); + assert.strictEqual(registry.hasBufferedResult('k'), true); + await registry.register('k'); + assert.strictEqual(registry.hasBufferedResult('k'), false); + }); + test('respondOrBuffer: a buffered `undefined` value still resolves a subsequent register', async () => { // Guards against the `get() !== undefined` sentinel bug: when T includes // undefined, a buffered undefined must be distinguished from "no entry" diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 931818e51fea74..b0d0ee60d4c1fe 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -2351,6 +2351,65 @@ suite('AgentSideEffects', () => { 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); + test('tool_ready is dropped when the tool completes while permission lookup is pending', async () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'disconnected-client' }, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References', + invocationMessage: 'Get VS Code API References', toolInput: '{"query":"test"}', + confirmationTitle: 'Allow tool call?', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, + }); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-stale-ready', + invocationMessage: 'Get VS Code API References', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'tc-stale-ready', + result: { + success: false, + pastTenseMessage: 'Get VS Code API References failed', + error: { message: 'Client disconnected' }, + }, + }); + + await Promise.resolve(); + + const toolCall = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts + .find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-stale-ready'); + assert.deepStrictEqual({ + status: toolCall?.kind === ResponsePartKind.ToolCall ? toolCall.toolCall.status : undefined, + readyActions: envelopes.filter(e => e.action.type === ActionType.ChatToolCallReady).length, + }, { + status: ToolCallStatus.Completed, + readyActions: 1, + }); + }); + test('tool_ready for an additional chat is emitted on that chat channel', async () => { setupSession(); const chatUri = buildChatUri(sessionUri.toString(), 'peer'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index a539ed302cd658..bb2f8b7b0f610f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -4119,7 +4119,10 @@ suite('CopilotAgentSession', () => { }); test('permission request before client tool handler emits only confirmation ready', async () => { - const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + clientSnapshot: snapshot, + activeClientToolSet: activeClientToolSetWith('test-client'), + }); mockSession.fire('tool.execution_start', { toolCallId: 'tc-ready-data', @@ -4268,6 +4271,53 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.resultType, 'success'); assert.strictEqual(result.textResultForLlm, 'buffered result'); }); + + test('completion arriving before the permission request unblocks the SDK', async () => { + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('client-disconnected', snapshot.tools); + const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-complete-before-permission', + toolName: 'my_tool', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + + session.handleClientToolCallComplete('tc-complete-before-permission', { + success: false, + pastTenseMessage: 'my_tool failed', + error: { message: 'Client disconnected' }, + }); + + const permissionPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-complete-before-permission', + toolName: 'my_tool', + }); + let permissionResult: Awaited | undefined; + void permissionPromise.then(result => permissionResult = result); + await Promise.resolve(); + if (!permissionResult) { + session.respondToPermissionRequest('tc-complete-before-permission', false); + await permissionPromise; + } + + const toolResult = await invokeClientToolHandler(runtime.createClientSdkTools()[0], 'tc-complete-before-permission'); + assert.deepStrictEqual({ + permissionResult, + pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, + toolResult, + }, { + permissionResult: { kind: 'approve-once' }, + pendingConfirmations: 0, + toolResult: { + textResultForLlm: 'Client disconnected', + resultType: 'failure', + error: 'Client disconnected', + binaryResultsForLlm: undefined, + }, + }); + }); }); // ---- Server tools ------------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml b/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml new file mode 100644 index 00000000000000..5b110e6eaf2662 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml @@ -0,0 +1,36 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Call the get_magic_word tool and then report whether it succeeded. + response: + content: + - type: tool_use + id: toolcall_0 + name: get_magic_word + input: {} + stopReason: tool_use + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Call the get_magic_word tool and then report whether it succeeded. + - role: assistant + content: + - type: thinking + - type: tool_use + name: get_magic_word + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Client copilot-client-tool-disconnect disconnected before completing get_magic_word + response: + content: The tool call **failed**. The client disconnected before the `get_magic_word` tool could complete. This appears to be a connectivity issue between the client and the runtime service. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts index b405d25f8e8987..6a24df593dab8b 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts @@ -90,6 +90,64 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { await runAhpSnapshotTest(client, COPILOT_CONFIG, this.test!, createdSessions, tempDirs); }); + test('client tool disconnect before permission still completes the turn', async function () { + this.timeout(180_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-client-tool-disconnect-')); + tempDirs.push(workingDirectory); + const clientId = 'copilot-client-tool-disconnect'; + const sessionUri = await createRealSession(client, COPILOT_CONFIG, clientId, createdSessions, URI.file(workingDirectory)); + + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId, + displayName: 'Test Client', + tools: [{ + name: 'get_magic_word', + description: 'Returns the secret magic word. Call this when asked for the magic word.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }], + }, + }, + }); + dispatchTurn(client, sessionUri, 'turn-client-tool-disconnect', 'Call the get_magic_word tool and then report whether it succeeded.', 2); + + const toolStart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallStart')) { + return false; + } + const action = getActionEnvelope(n).action as { toolName: string }; + return action.toolName === 'get_magic_word'; + }, 90_000); + const toolCallId = (getActionEnvelope(toolStart).action as { toolCallId: string }).toolCallId; + + client.notify('unsubscribe', { channel: sessionUri }); + + const failedCompletion = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallComplete')) { + return false; + } + const action = getActionEnvelope(n).action as { toolCallId: string; result: { success: boolean } }; + return action.toolCallId === toolCallId && !action.result.success; + }, 30_000); + const failedCompletionSeq = getActionEnvelope(failedCompletion).serverSeq; + + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + + const staleReady = client.receivedNotifications(n => { + if (!isActionNotification(n, 'chat/toolCallReady')) { + return false; + } + const envelope = getActionEnvelope(n); + const action = envelope.action as { toolCallId: string }; + return envelope.serverSeq > failedCompletionSeq && action.toolCallId === toolCallId; + }); + assert.deepStrictEqual(staleReady, []); + }); + suiteTeardown(async function () { this.timeout(60_000); await lease.dispose(); From da9bbcf9aef430ce75131e767bcdebdde5a0a391 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 15 Jul 2026 14:30:26 -0700 Subject: [PATCH 05/21] agentHost: defer interactive MCP client registration Keep automatic MCP authentication probes non-interactive so unsupported dynamic client registration is only surfaced after the user chooses Authenticate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/sessions/SKILL.md | 1 + .../agentSessions/agentHost/agentHostAuth.ts | 5 +-- .../agentSessions/agentHostAuth.test.ts | 35 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index e314bce7a9136b..202bc218180399 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -77,6 +77,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.) - **`NeedsInput` is still an active turn for live turn UI**: agent-host tool and input confirmations intentionally transition a running chat from `InProgress` to `NeedsInput` without ending `activeTurn`. Live status surfaces such as the chat input pills must use `isActiveSessionStatus` so they do not disappear until the next output returns the chat to `InProgress`. - **Agent-host-only exclusions for built-in client tools belong in `ClientToolSetsContribution`, not the global tool registration**: `AgentHostActiveClientService.getClientTools` advertises enabled members of every non-deprecated tool set, including extension-contributed sets. Omit an unsupported built-in tool from the client tool sets so normal Copilot chat can continue using it; do not treat this contribution as the sole Agent Host allowlist. +- **Non-interactive MCP authentication probes must not create dynamic authentication providers**: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With `allowInteraction: false`, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes the `mcpAuthenticationRequired` action. ## Capturing Feedback (meta-rule) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index 3bed9355e2573b..e635de18907943 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -279,7 +279,7 @@ export async function resolveMcpServerAuthentication( const scopes = options.scopes; for (const authorizationServer of protectedResource.authorization_servers ?? []) { const authorizationServerUri = URI.parse(authorizationServer); - const providerId = await getOrCreateProviderForMcpResource(authorizationServerUri, protectedResource, authenticationService, logService, options.logPrefix); + const providerId = await getOrCreateProviderForMcpResource(authorizationServerUri, protectedResource, authenticationService, logService, options.logPrefix, options.allowInteraction); if (!providerId) { continue; } @@ -317,10 +317,11 @@ async function getOrCreateProviderForMcpResource( authenticationService: IAuthenticationService, logService: ILogService, logPrefix: string, + allowCreation: boolean, ): Promise { const resourceUri = URI.parse(protectedResource.resource); const existing = await authenticationService.getOrActivateProviderIdForServer(authorizationServer, resourceUri); - if (existing) { + if (existing || !allowCreation) { return existing; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index 6006cb00080ea7..270cc06150c419 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -237,6 +237,41 @@ suite('resolveMcpServerAuthentication', () => { requestedScopes: [['notifications']], }); }); + + test('does not attempt dynamic provider creation without user interaction', async () => { + const warnings: string[] = []; + const logService = new class extends NullLogService { + override warn(message: string): void { + warnings.push(message); + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IAuthenticationService, createMockAuthService({})); + instantiationService.stub(IAuthenticationMcpAccessService, {}); + instantiationService.stub(IAuthenticationMcpService, { + getAccountPreference: () => undefined, + }); + instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(ILogService, logService); + + const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { + resource: 'https://mcp.example.com', + authorization_servers: ['not-a-valid-authorization-server'], + }, { + allowInteraction: false, + logPrefix: '[AgentHost]', + mcpServerId: 'server-id', + mcpServerName: 'Example', + mcpServerUrl: 'https://mcp.example.com', + scopes: [], + authenticate: async () => { }, + }); + + assert.deepStrictEqual({ result, warnings }, { + result: false, + warnings: [], + }); + }); }); suite('authenticateProtectedResources', () => { From c54ca24ad466d8491a327ab993a7e01a53280bbe Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:09 -0700 Subject: [PATCH 06/21] sessions: prompt timeline UX improvements (#326023) * sessions: prompt timeline rail becomes one scrollbar that fans in on scroll/hover Reshape the prompt timeline into a single scrollbar lane instead of a pill column sitting next to the transcript's native scrollbar. At rest it is a plain scrollbar (the rail draws its own thumb and the transcript's native vertical slider is hidden while the rail is active). On a deliberate gesture the thumb recedes and the prompt pills fan in over the same lane with a macOS-dock style fisheye: - Hard/fast scroll (wheel-velocity gated, capture phase so it works mid-content not just at the scroll limits) blooms the fan centred on where you are, and it glides continuously with the viewport as you keep scrolling. The reveal is gated on a real scrollTop change, so flicking against the top/bottom limit - or programmatic virtualization nudges - never opens it. - Hovering the lane blooms the fan and lets it follow the cursor; dragging the lane scrubs the transcript; clicking a pill still jumps; keyboard focus reveals the pills. It quietly collapses back to a plain scrollbar after a short linger once you stop scrolling and are not hovering. - Pills are laid out as an evenly-spaced dock centred in the lane (stable under virtualization) rather than scattered by content position; the two-tone green/red diff decoration and the hover card are preserved. Also reduces forced reflows on the hover/scroll paths (position the card and lane from cached geometry instead of getBoundingClientRect) and avoids churning the linger timer every scroll frame. Reduced-motion disables the fisheye but still reveals the calm dock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address prompt timeline PR feedback - normalize wheel deltas via StandardWheelEvent so line-mode devices trigger the fan - keep the native scrollbar when the rail is too narrow (below MIN_HOST_WIDTH) to replace it - use generic mouse listeners for lane scrub + mark guard so touch/iOS works - track keyboard focus: reveal a calm dock (:focus-within) with the fisheye suppressed - scale scrollTop into the estimated coordinate space before interpolating the fan focus - map the thumb/scrub math onto the list viewport height (ScrollbarState-style) - use --vscode-cornerRadius-circle for the thumb radius - extract the hard-wheel detector into a named helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * distance update --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/promptTimeline.css | 98 +++- .../browser/promptTimelineModel.ts | 15 +- .../browser/promptTimelineRail.ts | 5 + .../browser/promptTimelineRulerRail.ts | 460 +++++++++++++++++- .../browser/promptTimelineWidgetContrib.ts | 66 ++- .../contrib/chat/browser/widget/chatWidget.ts | 4 + 6 files changed, 619 insertions(+), 29 deletions(-) diff --git a/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css index 4977a823fe0bde..0963ad815058d3 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css +++ b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css @@ -22,9 +22,6 @@ --prompt-timeline-rail-width: 36px; /* Minimum clear space guaranteed between the message content and the rail marks. */ --prompt-timeline-content-gap: 24px; - /* Gutter reserved for the transcript's native scrollbar (its ~10px width + a small gap) so the - * marks sit to its left and it stays grabbable. */ - --prompt-timeline-scrollbar-gutter: 16px; } /* Reserve room on the transcript's right edge so message content clears the rail's marks. @@ -181,11 +178,81 @@ font-variant-numeric: tabular-nums; } -/* Overview-ruler style: the session compressed into the rail height like the editor overview ruler. Marks sit in a gutter to the LEFT of the transcript's native scrollbar (via the right inset) so the pills never overlap the slider and the scrollbar stays grabbable. The gutter is set from the real scrollbar width by the contribution; the fallback covers the default 10px slider + a small gap. */ +/* Overview-ruler style: the session compressed into the rail height like the editor overview ruler. + * The marks column IS the scrollbar lane: it sits at the transcript's right edge over the (hidden) + * native slider, and the rail draws its own thumb below the marks. At rest the lane is a plain + * scrollbar (just the thumb); on engagement the thumb recedes and the marks fan in over the same + * column, so the scrollbar visually morphs into the fan. */ .prompt-timeline-ruler-marks { position: absolute; - inset: 0 var(--prompt-timeline-scrollbar-gutter, 16px) 0 0; + inset: 0 0 0 auto; + width: 22px; + /* The whole column is the hover/scrub surface: hovering blooms the fisheye "fan" and lets it + * follow the cursor; dragging scrubs the transcript (see the rail's pointer handlers). */ + pointer-events: auto; + /* The marks stay quiet (invisible) at rest and during gentle scrolling so the transcript reads + * calm — only a deliberate gesture surfaces them: hovering the lane (`:hover`), the fisheye bloom + * on a hard scroll (`.engaged`), a scrub drag (`.scrubbing`), or keyboard focus inside the rail + * (`:focus-within`). */ + opacity: 0; + transition: opacity 200ms ease; + z-index: 2; +} + +/* Reveal the marks whenever the timeline is engaged: while scrolling + its linger (`.engaged`, which + * also covers reduced-motion where the fan is disabled), while hovering the lane, while scrubbing, or + * whenever keyboard focus is inside the rail (so tabbing to a mark and "Go to Prompt" always show it). */ +.prompt-timeline-rail.engaged .prompt-timeline-ruler-marks, +.prompt-timeline-rail:hover .prompt-timeline-ruler-marks, +.prompt-timeline-rail.scrubbing .prompt-timeline-ruler-marks, +.prompt-timeline-rail:focus-within .prompt-timeline-ruler-marks { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-marks { + transition: none; + } +} + +/* The rail's own scrollbar thumb (the transcript's native slider is hidden while the rail is active). + * A plain, translucent scrollbar at rest; it fades out as the fan blooms — or stays put while you + * drag it to scrub — so the lane reads as one surface morphing scrollbar <-> fan. Non-interactive: + * the marks column above owns hover + scrub. */ +.prompt-timeline-ruler-thumb { + position: absolute; + right: 4px; + width: 9px; + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-scrollbarSlider-background); pointer-events: none; + transition: opacity 160ms ease; + z-index: 1; +} + +.prompt-timeline-ruler-thumb.hidden { + display: none; +} + +/* The thumb recedes whenever the marks are revealed (engaged/hover/keyboard focus), but stays visible + * while scrubbing so dragging the lane feels like grabbing a scrollbar. */ +.prompt-timeline-rail.engaged:not(.scrubbing) .prompt-timeline-ruler-thumb, +.prompt-timeline-rail:hover:not(.scrubbing) .prompt-timeline-ruler-thumb, +.prompt-timeline-rail:focus-within:not(.scrubbing) .prompt-timeline-ruler-thumb { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-thumb { + transition: none; + } +} + +/* The rail is the scrollbar while it is active, so hide the transcript's own vertical slider for a + * single lane. Scoped to the top-level transcript list via a direct-child chain (nested scrollables + * inside rows keep their sliders) and only while the rail is actually showing (`.prompt-timeline-active`). */ +.prompt-timeline-host.prompt-timeline-active .interactive-list > .monaco-list > .monaco-scrollable-element > .scrollbar.vertical { + display: none; } /* Each mark is a >=24px hit target positioned at its proportional scroll offset. */ @@ -227,7 +294,26 @@ height: 3px; border-radius: 2px; background-color: var(--vscode-scrollbarSlider-background); - transition: width 80ms ease, height 80ms ease, background-color 80ms ease; + /* `transform` is animated for the fisheye "fan" magnification; keep origin at the transcript + * edge so the bar grows leftward, never across the scrollbar. */ + transform-origin: right center; + transition: transform 90ms ease, width 80ms ease, height 80ms ease, background-color 80ms ease; +} + +/* The mark button carries the fan's neighbour-spread (translateY); transition it so bloom and + * collapse both ease. `top` is only transitioned under `.glide` (drift), so this stays independent. */ +.prompt-timeline-ruler-mark { + transition: transform 90ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-bar { + transition: width 80ms ease, height 80ms ease, background-color 80ms ease; + } + + .prompt-timeline-ruler-mark { + transition: none; + } } /* Two-tone edited signal: a green added segment and a red removed segment, sized by the turn's split. */ diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts index 9ee7f466c19f27..69172bd3155ae9 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts @@ -47,6 +47,12 @@ export interface IPromptScrollLayout { readonly marks: readonly { readonly requestId: string; readonly top: number }[]; /** Total content height in the estimated space, matching `marks`. */ readonly total: number; + /** Current scroll offset (px, the transcript's real scroll space) — drives the rail's own scrollbar thumb. */ + readonly scrollTop: number; + /** Full scrollable content height (px, the transcript's real scroll space). */ + readonly scrollHeight: number; + /** Visible viewport height (px) of the transcript list — the scrollbar's `visibleSize`. */ + readonly viewportHeight: number; } /** A single tick shown on the prompt timeline rail. */ @@ -203,9 +209,10 @@ export class PromptTimelineModel extends Disposable { /** * The prompts' positions for the overview-ruler rail, in an *estimated* - * content space that stays stable while the transcript virtualizes. There is no - * viewport thumb: the native scrollbar is the drag affordance and the active - * mark is the "you-are-here", so the rail never draws a second slider. + * content space that stays stable while the transcript virtualizes. The rail + * draws its own scrollbar thumb from `scrollTop`/`scrollHeight` (the transcript's + * native scrollbar is hidden while the rail is active) so the whole lane is one + * surface: a plain scrollbar that blooms into the prompt fan on engagement. * * The chat list's own height model (`getElementTop`/`scrollHeight`) guesses * every un-rendered row at one flat default height (200px). Real turns are @@ -231,7 +238,7 @@ export class PromptTimelineModel extends Disposable { marks.push({ requestId: item.id, top: tops[i] }); } } - return { marks, total }; + return { marks, total, scrollTop: this.widget.scrollTop, scrollHeight: this.widget.scrollHeight, viewportHeight: this.widget.viewportHeight }; } /** diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts index 303f433c8c4c20..f8dcce564f0ba2 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts @@ -24,6 +24,8 @@ export interface IPromptTimelineRail extends IDisposable { /** Fired when a mark is chosen (click / keyboard), with its request id. */ readonly onDidSelect: Event; + /** Fired while dragging the rail lane to scrub, with the requested transcript scroll offset (px). */ + readonly onDidScrub: Event; /** Fired to review all of a prompt's changes. */ readonly onDidReview: Event; /** Fired to review a single changed file of a prompt. */ @@ -31,6 +33,9 @@ export interface IPromptTimelineRail extends IDisposable { setFilesProvider(provider: (tick: PromptTick) => readonly PromptFileDiff[]): void; setTicks(ticks: readonly PromptTick[]): void; + + /** Records a hard/fast wheel flick; the fan blooms only if a real transcript scroll follows shortly after. */ + notifyHardWheel(): void; setActive(requestId: string | undefined): void; focusTick(requestId: string): void; setHostWidth(width: number): void; diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts index b07d7d55db9ac1..e1f085ef044e50 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, append, clearNode, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableGenericMouseUpListener, addDisposableListener, append, clearNode, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -17,10 +18,29 @@ import './media/promptTimeline.css'; /** Minimum clickable target size (WCAG 2.5.8) for each mark's hit area. */ const MIN_TARGET = 24; -/** Below this transcript width the rail hides so it does not crowd the content. */ -const MIN_HOST_WIDTH = 320; +/** Below this transcript width the rail hides so it does not crowd the content (and the native scrollbar is kept). */ +export const MIN_HOST_WIDTH = 320; /** Skip re-positioning a mark for sub-pixel drift, so estimate noise doesn't cause micro-jitter. */ const RELAYOUT_MIN_DELTA = 0.5; +/** Fisheye "fan" lens: standard deviation (px) of the magnification falloff around the focus. */ +const FAN_SIGMA = 40; +/** Fisheye "fan" lens: how far (px) neighbouring marks are pushed apart around the focus. */ +const FAN_SPREAD = 14; +/** The fan lingers this long (ms) after the last scroll — while the pointer is away — before collapsing. */ +const FAN_LINGER = 2000; +/** A hard wheel flick only reveals the fan if the transcript actually scrolls within this window (ms) — so flicking against the top/bottom limit, which moves nothing, never blooms it. */ +const HARD_WHEEL_REVEAL_WINDOW = 200; +/** Minimum height (px) of the rail's own scrollbar thumb so it stays grabbable on tall transcripts. */ +const THUMB_MIN_HEIGHT = 24; +/** + * Pill placement. `'proportional'` scatters pills at their real scroll position (an overview ruler); + * `'even'` stacks them as an evenly-spaced dock centred in the lane (stable under virtualization, and + * tidier when big responses would otherwise cluster the pills). The scrollbar thumb stays proportional + * either way, and scrubbing hides the pills, so `'even'` does not make dragging feel disconnected. + */ +const PILL_LAYOUT: 'proportional' | 'even' = 'even'; +/** Even layout: vertical gap (px) between pill centres — also the min so hit targets never overlap. */ +const EVEN_PILL_SPACING = 26; interface IMarkEntry { tick: PromptTick; @@ -28,6 +48,8 @@ interface IMarkEntry { readonly bar: HTMLElement; /** Last applied `top` (px) so tiny relayout deltas can be skipped. */ lastTop?: number; + /** Proportional (pre-fan) centre (px) from the last layout, used as the fan's rest position. */ + baseCenter?: number; } /** @@ -41,6 +63,8 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli private readonly _domNode: HTMLElement; private readonly _marksContainer: HTMLElement; + /** The rail's own scrollbar thumb (the transcript's native slider is hidden while the rail is active). Shown at rest as a plain scrollbar; recedes as the fan blooms. */ + private readonly _thumb: HTMLElement; private readonly _card: PromptTimelineCard; private readonly _markDisposables = this._register(new DisposableStore()); private readonly _marks: IMarkEntry[] = []; @@ -55,10 +79,37 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli private _railHeight = 0; /** Coalesces scroll-driven relayouts to one per animation frame. */ private readonly _relayoutScheduled = this._register(new MutableDisposable()); + /** Lane-local Y the fisheye "fan" magnifies around, or undefined when the fan is at rest. */ + private _fanCenter: number | undefined; + /** Cached top (client px) of the marks column, captured on pointer-enter so the fan can follow the cursor without a per-move reflow. */ + private _laneTop = 0; + /** Cached client Y of the rail's top edge, refreshed on resize; used to place the hover card and derive the lane-local pointer Y without a per-hover forced reflow. */ + private _domTop: number | undefined; + /** True while the pointer is over the lane (keeps the fan open; the linger only collapses once it leaves). */ + private _hovering = false; + /** Timestamp (ms) of the last hard/fast wheel flick; the fan blooms only if a real scroll follows it within {@link HARD_WHEEL_REVEAL_WINDOW}. */ + private _hardWheelAt = 0; + /** Last scroll offset seen, to detect real transcript movement (vs. a wheel that hit the scroll limit and moved nothing). */ + private _lastScrollTop: number | undefined; + /** Collapses the fan {@link FAN_LINGER}ms after the last scroll, unless the pointer is keeping it open. */ + private readonly _fanHide = this._register(new MutableDisposable()); + /** Timestamp (ms) of the last scroll/leave that should keep the fan up; the linger timer re-checks this instead of being churned every scroll frame. */ + private _lastFanActivityAt = 0; + /** When the user prefers reduced motion the fan is disabled (marks stay their calm rest size). */ + private _reducedMotion = false; + /** True while the user is dragging the lane to scrub the transcript. */ + private _scrubbing = false; + /** True while keyboard focus is inside the rail: the marks stay revealed (`:focus-within`) but the fisheye is suppressed. */ + private _focused = false; + /** Window listeners for the in-progress scrub drag; cleared on mouse-up (and on rail dispose). */ + private readonly _scrubSession = this._register(new MutableDisposable()); private readonly _onDidSelect = this._register(new Emitter()); readonly onDidSelect: Event = this._onDidSelect.event; + private readonly _onDidScrub = this._register(new Emitter()); + readonly onDidScrub: Event = this._onDidScrub.event; + private readonly _onDidReview = this._register(new Emitter()); readonly onDidReview: Event = this._onDidReview.event; @@ -74,6 +125,10 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli this._domNode.setAttribute('role', 'toolbar'); this._domNode.setAttribute('aria-orientation', 'vertical'); this._marksContainer = append(this._domNode, $('.prompt-timeline-ruler-marks')); + // The rail's own scrollbar thumb. It sits under the marks and shows the viewport position as + // a plain scrollbar at rest; while the fan is bloomed it fades out so the lane reads as one + // surface morphing from scrollbar → fan (the transcript's native slider is hidden via CSS). + this._thumb = append(this._domNode, $('.prompt-timeline-ruler-thumb')); this._card = this._register(new PromptTimelineCard(this._domNode)); this._register(this._card.onDidReview(tick => this._onDidReview.fire(tick))); this._register(this._card.onDidReviewFile(e => this._onDidReviewFile.fire(e))); @@ -81,8 +136,56 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli // Toolbar keyboard model: one Tab stop, Arrow/Home/End move between marks. this._register(addDisposableListener(this._marksContainer, EventType.KEY_DOWN, e => this._onMarksKeyDown(e))); + // The marks column IS the scrollbar lane. Hovering anywhere along it blooms the fisheye "fan" + // and lets it FOLLOW the cursor (a macOS-dock feel); dragging it scrubs the transcript like a + // scrollbar. The lane's top edge is cached (refreshed on resize) so each hover/move converts + // the pointer Y to lane-local space without a per-event getBoundingClientRect (a forced reflow + // that would stutter while the transcript's styles are dirty during scroll). + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_ENTER, e => { + this._laneTop = this._laneTopNow(); + this._hovering = true; + this._fanHide.clear(); // hovering keeps the fan open — no linger countdown + if (!this._scrubbing) { + this._engage(e.clientY - this._laneTop); + } + })); + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_MOVE, e => { + this._hovering = true; + if (!this._scrubbing) { + this._engage(e.clientY - this._laneTop); + } + })); + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_LEAVE, () => { + this._hovering = false; + if (!this._scrubbing) { + this._scheduleFanHide(); + } + })); + // Drag the lane (anywhere that is not a mark) to scrub the transcript, like grabbing a + // scrollbar. Generic mouse-down so it also works with touch/pointer on iOS. + this._register(addDisposableGenericMouseDownListener(this._marksContainer, e => this._beginScrub(e))); + + // The fan is a pointer-only flourish, so it must respect reduced-motion. Read it now and + // track changes; keyboard users always get the calm, static marks + card + navigation. + const win = getWindow(this._domNode); + const reducedMotionQuery = win.matchMedia?.('(prefers-reduced-motion: reduce)'); + if (reducedMotionQuery) { + this._reducedMotion = reducedMotionQuery.matches; + this._register(addDisposableListener(reducedMotionQuery, 'change', () => { + this._reducedMotion = reducedMotionQuery.matches; + this._applyFan(); + })); + } + + // Keyboard focus reveals a calm dock: the marks stay up (`:focus-within` in CSS) but the fisheye + // is suppressed, so tabbing through never leaves the pills magnified from an earlier scroll. + this._register(addDisposableListener(this._domNode, EventType.FOCUS_IN, () => { + this._focused = true; + this._collapseFan(); + })); this._register(addDisposableListener(this._domNode, EventType.FOCUS_OUT, () => { if (!this._domNode.contains(getWindow(this._domNode).document.activeElement)) { + this._focused = false; this._card.scheduleHide(); } })); @@ -121,6 +224,9 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli this._renderMark(entry, tick); const requestId = tick.requestId; this._markDisposables.add(addDisposableListener(button, EventType.CLICK, () => this._onDidSelect.fire(requestId))); + // A press on a mark must not start a lane scrub — let the click jump to the prompt. Generic + // so it also intercepts the pointer-down used on iOS. + this._markDisposables.add(addDisposableGenericMouseDownListener(button, e => e.stopPropagation())); this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_ENTER, () => this._showCard(entry))); this._markDisposables.add(addDisposableListener(button, EventType.FOCUS, () => { this._showCard(entry); this._updateTabStops(this._marks.indexOf(entry)); })); this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_LEAVE, () => this._card.scheduleHide())); @@ -185,9 +291,81 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } } + /** + * Records a hard/fast wheel flick. The fan does NOT bloom here — it blooms only if the transcript + * actually scrolls shortly after (see {@link setScrollLayout}). This way a hard flick against the + * top/bottom scroll limit, which moves nothing, never reveals the fan. + */ + notifyHardWheel(): void { + this._hardWheelAt = Date.now(); + } + + /** Lane-local Y of the active mark (the prompt currently scrolled to), or the nearest visible one. */ + private _activeCenter(): number | undefined { + const active = this._marks.find(m => m.tick.requestId === this._activeRequestId && m.baseCenter !== undefined); + if (active?.baseCenter !== undefined) { + return active.baseCenter; + } + // Fall back to the last laid-out mark (or the first) so a scroll still blooms somewhere real. + const laidOut = this._marks.filter(m => m.baseCenter !== undefined); + return laidOut.at(-1)?.baseCenter; + } + + /** + * Lane-local Y for the fisheye focus while SCROLLING: glides continuously with the viewport by + * interpolating between pills. Each prompt has a content position (`layout.marks[].top`) and a dock + * position (`baseCenter`); we find where the viewport (`scrollTop`) sits between two prompts in + * content space and place the focus at the matching fraction between their dock positions. So the + * fisheye travels smoothly through the pills as you scroll (rather than snapping at prompt + * boundaries), while still tracking the real scroll position. Returns `undefined` if not laid out. + */ + private _scrollFanCenter(): number | undefined { + const layout = this._layout; + if (!layout) { + return undefined; + } + const topById = new Map(layout.marks.map(m => [m.requestId, m.top])); + const pts: { contentTop: number; center: number }[] = []; + for (const entry of this._marks) { + const contentTop = topById.get(entry.tick.requestId); + if (contentTop !== undefined && entry.baseCenter !== undefined) { + pts.push({ contentTop, center: entry.baseCenter }); + } + } + if (pts.length === 0) { + return undefined; + } + pts.sort((a, b) => a.contentTop - b.contentTop); + // `contentTop`s are in the adaptive ESTIMATED space (summing to `layout.total`), but + // `layout.scrollTop`/`scrollHeight` are the transcript's REAL scroll space. Under virtualization + // those spaces differ, so scale the scroll position into the estimated space before comparing. + const scrollTop = layout.scrollHeight > 0 + ? (layout.scrollTop / layout.scrollHeight) * layout.total + : layout.scrollTop; + if (scrollTop <= pts[0].contentTop) { + return pts[0].center; + } + const last = pts[pts.length - 1]; + if (scrollTop >= last.contentTop) { + return last.center; + } + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + if (scrollTop >= a.contentTop && scrollTop <= b.contentTop) { + const span = b.contentTop - a.contentTop; + const frac = span > 0 ? (scrollTop - a.contentTop) / span : 0; + return a.center + frac * (b.center - a.center); + } + } + return last.center; + } + setActive(requestId: string | undefined): void { this._activeRequestId = requestId; this._updateActiveClasses(); + // Note: the scroll-driven fan follow is handled continuously in `_relayout` (it glides with the + // viewport), so we do not re-centre here — that would snap the fan at prompt boundaries. } focusTick(requestId: string): void { @@ -202,11 +380,46 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } setScrollLayout(layout: IPromptScrollLayout | undefined): void { + const prevScrollTop = this._lastScrollTop; this._layout = layout; + if (layout) { + this._lastScrollTop = layout.scrollTop; + // Only react to a REAL scroll movement. A wheel flick against the top/bottom limit fires + // wheel events (so notifyHardWheel runs) but doesn't change scrollTop, so it never reveals + // the fan here. Programmatic nudges during virtualization re-measure lack a recent hard + // wheel, so they don't reveal it either. + if (prevScrollTop !== undefined && Math.abs(layout.scrollTop - prevScrollTop) > 0.5) { + this._onScrolled(); + } + } // Scroll fires many events per frame; coalesce so we lay out (and touch the DOM) once. this._scheduleRelayout(); } + /** + * Handles a real transcript scroll: blooms the fan if it followed a deliberate hard flick, and + * keeps it alive (re-arms the linger) while you keep scrolling. Pointer hover/scrub own the fan + * on their own, so this defers to them. + */ + private _onScrolled(): void { + if (this._hovering || this._scrubbing || this._focused) { + return; + } + if (this._domNode.classList.contains('engaged')) { + // Already open: keep it up while actively scrolling (the glide happens in `_relayout`). + this._scheduleFanHide(); + return; + } + // Not open yet: only a deliberate hard flick that actually moved the transcript blooms it. + if (Date.now() - this._hardWheelAt <= HARD_WHEEL_REVEAL_WINDOW) { + const center = this._scrollFanCenter() ?? this._activeCenter(); + if (center !== undefined) { + this._engage(center); + this._scheduleFanHide(); + } + } + } + /** Coalesces relayout to at most once per animation frame. */ private _scheduleRelayout(): void { if (this._relayoutScheduled.value) { @@ -225,6 +438,8 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli const layout = this._layout; const overflowing = this._hostWidth < MIN_HOST_WIDTH; this._domNode.classList.toggle('overflowing', overflowing); + // Position the rail's own scrollbar thumb (independent of the marks, which may be absent). + this._layoutThumb(); if (overflowing || height <= 0 || !layout || layout.total <= 0) { return; } @@ -238,19 +453,29 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli if (top === undefined) { entry.button.classList.add('hidden'); entry.lastTop = undefined; + entry.baseCenter = undefined; + entry.button.style.transform = ''; + entry.bar.style.transform = ''; continue; } entry.button.classList.remove('hidden'); visible.push({ entry, center: top * scale }); } - // Prompts can sit arbitrarily close in content space (a short turn, or after height - // re-estimates settle), which would let the >=24px hit targets overlap. Push adjacent - // marks apart to keep a full target's spacing while staying as close to their - // proportional position as the rail allows. - spaceMarkCenters(visible, height, MIN_TARGET); + if (PILL_LAYOUT === 'even') { + // Evenly-spaced dock, centred vertically as a group. Stable under virtualization (pills do + // not drift as row heights re-measure) and tidy when big responses would cluster them. + this._spaceEvenCenters(visible, height); + } else { + // Prompts can sit arbitrarily close in content space (a short turn, or after height + // re-estimates settle), which would let the >=24px hit targets overlap. Push adjacent + // marks apart to keep a full target's spacing while staying as close to their + // proportional position as the rail allows. + spaceMarkCenters(visible, height, MIN_TARGET); + } for (const { entry, center } of visible) { + entry.baseCenter = center; // The button is a >=24px hit target centered on the mark's (spaced) position. const y = center - MIN_TARGET / 2; // Skip sub-pixel drift so estimate noise doesn't jitter the marks. @@ -260,6 +485,200 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli entry.lastTop = y; entry.button.style.top = `${y}px`; } + + // While the fan is open because of scrolling (not steered by the pointer, and not while keyboard + // focus is showing the calm dock), glide its focus with the viewport so the fisheye travels + // smoothly through the pills as you scroll. + if (this._domNode.classList.contains('engaged') && !this._hovering && !this._scrubbing && !this._focused) { + const scrollCenter = this._scrollFanCenter(); + if (scrollCenter !== undefined) { + this._fanCenter = scrollCenter; + } + } + + // Re-apply the pointer fisheye against the freshly measured rest positions. + this._applyFan(); + } + + /** + * Even (dock) placement: stacks the pills at a fixed spacing and centres the whole group in the + * lane. If the group is taller than the lane it distributes across the full height instead, so a + * long session still fits. Mutates each item's `center` in place. + */ + private _spaceEvenCenters(visible: { entry: IMarkEntry; center: number }[], height: number): void { + const n = visible.length; + if (n === 0) { + return; + } + const groupHeight = n * EVEN_PILL_SPACING; + let start: number; + let step: number; + if (groupHeight <= height) { + // Compact group centred vertically. + step = EVEN_PILL_SPACING; + start = (height - groupHeight) / 2 + step / 2; + } else { + // Too many to fit at the ideal spacing: spread evenly across the full height. + step = (height - EVEN_PILL_SPACING) / (n - 1); + start = EVEN_PILL_SPACING / 2; + } + for (let i = 0; i < n; i++) { + visible[i].center = start + i * step; + } + } + + /** + * The rail's scrollbar geometry, mapping the transcript's real scroll space onto the rail track, + * exactly like the editor's {@link ScrollbarState}: the thumb size is the viewport's share of the + * content (floored to a grabbable minimum), and the thumb travels `track - thumbSize` as the scroll + * position travels `scrollHeight - viewportHeight`. Returns `undefined` when there is nothing to + * scroll or the rail is too narrow to act as the scrollbar. + */ + private _thumbMetrics(): { size: number; ratio: number; maxScroll: number } | undefined { + const track = this._railHeight; + const layout = this._layout; + if (!layout || track <= 0 || this._hostWidth < MIN_HOST_WIDTH) { + return undefined; + } + const viewport = layout.viewportHeight; + const scrollSize = layout.scrollHeight; + if (viewport <= 0 || scrollSize <= viewport + 1) { + return undefined; // content fits — no scrollbar needed + } + const size = Math.max(THUMB_MIN_HEIGHT, Math.floor(viewport / scrollSize * track)); + const maxScroll = scrollSize - viewport; + const ratio = (track - size) / maxScroll; + return { size, ratio, maxScroll }; + } + + /** Positions the rail's own scrollbar thumb from the transcript's real scroll offset, or hides it when there is nothing to scroll. */ + private _layoutThumb(): void { + const metrics = this._thumbMetrics(); + if (!metrics) { + this._thumb.classList.add('hidden'); + return; + } + const top = Math.max(0, Math.min(this._railHeight - metrics.size, this._layout!.scrollTop * metrics.ratio)); + this._thumb.classList.remove('hidden'); + this._thumb.style.top = `${top}px`; + this._thumb.style.height = `${metrics.size}px`; + } + + /** Begins a lane scrub (grab the scrollbar): scrolls to the pressed position and tracks the drag. */ + private _beginScrub(e: MouseEvent): void { + if (!this._thumbMetrics()) { + return; // nothing to scroll + } + e.preventDefault(); + this._scrubbing = true; + this._laneTop = this._laneTopNow(); + // Collapse the fan while scrubbing so the lane reads as a clean scrollbar being dragged. + this._collapseFan(); + this._domNode.classList.add('scrubbing'); + this._scrubTo(e.clientY); + const session = new DisposableStore(); + session.add(addDisposableGenericMouseMoveListener(getWindow(this._domNode), (ev: MouseEvent) => this._scrubTo(ev.clientY))); + session.add(addDisposableGenericMouseUpListener(getWindow(this._domNode), () => { + this._scrubbing = false; + this._domNode.classList.remove('scrubbing'); + this._scrubSession.clear(); + // Linger briefly after a scrub so the timeline stays up while the user reads where they landed. + if (!this._hovering) { + const center = this._activeCenter(); + if (center !== undefined) { + this._engage(center); + } + this._scheduleFanHide(); + } + })); + this._scrubSession.value = session; + } + + /** Maps a client Y on the lane to a transcript scroll offset and requests it (viewport centred on the cursor). */ + private _scrubTo(clientY: number): void { + const metrics = this._thumbMetrics(); + if (!metrics) { + return; + } + // Centre the thumb on the cursor, then invert the thumb->scroll ratio, like ScrollbarState. + const desiredThumbTop = (clientY - this._laneTop) - metrics.size / 2; + const scrollTop = metrics.ratio > 0 ? desiredThumbTop / metrics.ratio : 0; + this._onDidScrub.fire(Math.max(0, Math.min(metrics.maxScroll, scrollTop))); + } + + /** + * Fisheye "fan": magnify the marks near {@link _fanCenter} and gently spread their neighbours + * apart, so a dense cluster becomes easy to read and click. It is a pointer-only flourish layered + * on top of the proportional layout — the marks' `top` (owned by `_relayout`) is untouched; the + * fan only adds a CSS `transform`, so keyboard navigation and the base layout are unaffected. + * Disabled entirely under reduced-motion. + */ + private _applyFan(): void { + const center = this._fanCenter; + const fanning = center !== undefined && !this._reducedMotion; + for (const entry of this._marks) { + if (entry.baseCenter === undefined) { + continue; + } + if (!fanning) { + entry.button.style.transform = ''; + entry.bar.style.transform = ''; + continue; + } + const d = entry.baseCenter - center!; + const m = Math.exp(-(d * d) / (2 * FAN_SIGMA * FAN_SIGMA)); + // Spread neighbours away from the focus (dock feel) and grow the focused bar the most. + entry.button.style.transform = `translateY(${FAN_SPREAD * Math.tanh(d / FAN_SIGMA)}px)`; + entry.bar.style.transform = `scale(${1 + m * 0.9}, ${1 + m * 0.6})`; + } + } + + /** + * Opens the fan at {@link center} (lane-local Y): reveals the marks (via `.engaged`) and applies + * the fisheye. Reveal happens even under reduced motion (the marks just don't magnify). + */ + private _engage(center: number): void { + this._domNode.classList.add('engaged'); + this._fanCenter = center; + this._applyFan(); + } + + /** Collapses the fan back to the plain scrollbar (marks hidden, no fisheye). */ + private _collapseFan(): void { + if (!this._domNode.classList.contains('engaged')) { + return; + } + this._domNode.classList.remove('engaged'); + this._fanCenter = undefined; + this._applyFan(); + } + + /** + * (Re)starts the linger countdown: {@link FAN_LINGER}ms after the last scroll the fan collapses — + * but only if the pointer is not keeping it open. Called on every scroll frame and when the pointer + * leaves, so it avoids churning the timer: it just stamps the activity time and, when the single + * running timer fires, it re-arms for the remaining time if more scrolling happened since. + */ + private _scheduleFanHide(): void { + this._lastFanActivityAt = Date.now(); + if (!this._fanHide.value) { + this._armFanHide(FAN_LINGER); + } + } + + private _armFanHide(delay: number): void { + this._fanHide.value = disposableTimeout(() => { + this._fanHide.clear(); + if (this._hovering) { + return; // hovering keeps it up; leaving re-arms the countdown + } + const remaining = FAN_LINGER - (Date.now() - this._lastFanActivityAt); + if (remaining > 0) { + this._armFanHide(remaining); // more scrolling happened since — keep waiting + } else { + this._collapseFan(); + } + }, delay); } private _updateActiveClasses(): void { @@ -274,10 +693,25 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } } + /** Lane-local Y (client px) of the marks column top, from the cached rail top (refreshed on resize). Reads layout lazily only if the cache is not yet primed, so hovering never forces a reflow mid-scroll. */ + private _laneTopNow(): number { + if (this._domTop === undefined) { + this._domTop = this._domNode.getBoundingClientRect().top; + } + // The marks column is inset:0 at the top, so its client top equals the rail's. + return this._domTop; + } + private _showCard(entry: IMarkEntry): void { - const markRect = entry.button.getBoundingClientRect(); - const domRect = this._domNode.getBoundingClientRect(); - this._card.show(entry.tick, markRect.top - domRect.top + markRect.height / 2); + // Position the card from the mark's known lane-local centre instead of reading + // getBoundingClientRect (a forced synchronous layout that stutters while the transcript's + // styles are dirty during scroll). The marks column is inset:0 at the top, so `baseCenter` + // is the Y relative to the rail; the hovered mark sits near the fan focus, where its + // magnification translate is ~0, so this matches the visible position. Falls back to a + // measured rect only if the mark has not been laid out yet. + const centerY = entry.baseCenter + ?? (entry.button.getBoundingClientRect().top - this._domNode.getBoundingClientRect().top + MIN_TARGET / 2); + this._card.show(entry.tick, centerY); } private _ensureResizeObserver(): void { @@ -290,8 +724,10 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } this._resizeObserverReady = true; const observer = new ResizeObserverCtor(() => { - // Height only changes here (window/input-part resize); refresh the cache and lay out. + // Height only changes here (window/input-part resize); refresh the cached height and top + // (used by hover/scrub to avoid per-event reflows) and lay out. this._railHeight = this._domNode.clientHeight; + this._domTop = this._domNode.getBoundingClientRect().top; this._relayout(); }); observer.observe(this._domNode); diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts index 77cba5b5cf97b2..935ff0eecdff79 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getWindow } from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { addDisposableListener, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { IMouseWheelEvent, StandardWheelEvent } from '../../../../base/browser/mouseEvent.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -14,7 +15,12 @@ import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/con import { MIN_PROMPTS, PROMPT_TIMELINE_CONTRIB_ID, PROMPT_TIMELINE_ENABLED_SETTING } from '../common/promptTimeline.js'; import { PromptTimelineModel, PromptEntry } from './promptTimelineModel.js'; import { IPromptTimelineRail } from './promptTimelineRail.js'; -import { PromptTimelineRulerRail } from './promptTimelineRulerRail.js'; +import { MIN_HOST_WIDTH, PromptTimelineRulerRail } from './promptTimelineRulerRail.js'; + +/** Normalized wheel distance (device-independent units, ~1 per notch) accumulated within {@link WHEEL_WINDOW_MS} to count as a hard/fast scroll. */ +const HARD_WHEEL_DISTANCE = 20; +/** Rolling window for the wheel-velocity accumulator; a pause longer than this resets it. */ +const WHEEL_WINDOW_MS = 120; /** * Per-widget contribution that overlays a prompt timeline rail on the chat @@ -33,6 +39,8 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg /** Holds the model, rail and all their wiring while the feature is enabled. */ private readonly _enablement = this._register(new DisposableStore()); private _enabled = false; + /** Latest tick count is at or above {@link MIN_PROMPTS}; combined with the host width to decide whether the rail replaces the native scrollbar. */ + private _hasEnoughPrompts = false; constructor( private readonly widget: IChatWidget, @@ -64,6 +72,7 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg this._enablement.clear(); this._model = undefined; this._rail = undefined; + this._hasEnoughPrompts = false; if (enabled) { this._createRail(); } @@ -80,14 +89,22 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg rail.setFilesProvider(tick => model.getRequestFiles(tick)); this._enablement.add(rail.onDidSelect(requestId => model.reveal(requestId))); + // Dragging the rail lane scrubs the transcript (the rail is the scrollbar now, so it drives scroll). + this._enablement.add(rail.onDidScrub(scrollTop => { (this.widget as ChatWidget).scrollTop = scrollTop; })); this._enablement.add(rail.onDidReview(tick => { void model.reviewChanges(tick); })); this._enablement.add(rail.onDidReviewFile(e => { void model.reviewChanges(e.tick, e.file); })); + // A deliberate hard/fast scroll reveals the fan; capture phase so it is seen before the + // transcript's ScrollableElement consumes the wheel mid-content (see `_registerHardWheelDetector`). + this._enablement.add(this._registerHardWheelDetector(rail)); + this._enablement.add(autorun(reader => { const ticks = model.ticks.read(reader); // Toggle visibility before rendering so the rail's fit measurement in // setTicks runs against the displayed (non-zero height) element. - rail.domNode.classList.toggle('hidden', ticks.length < MIN_PROMPTS); + this._hasEnoughPrompts = ticks.length >= MIN_PROMPTS; + rail.domNode.classList.toggle('hidden', !this._hasEnoughPrompts); + this._updateNativeScrollbarHidden(); rail.setTicks(ticks); })); @@ -108,7 +125,7 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg // Anchor the absolutely-positioned overlay to the chat widget via a class // we own, removed on teardown so we never leave the foreign container mutated. host.classList.add('prompt-timeline-host'); - this._enablement.add(toDisposable(() => host.classList.remove('prompt-timeline-host'))); + this._enablement.add(toDisposable(() => host.classList.remove('prompt-timeline-host', 'prompt-timeline-active'))); host.appendChild(railNode); this._enablement.add(toDisposable(() => railNode.remove())); @@ -118,14 +135,49 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg railNode.style.setProperty('--prompt-timeline-bottom', `${inputPart.height.read(reader)}px`); })); - // Report the host width so the rail can hide on very narrow transcripts. + // Report the host width so the rail can hide on very narrow transcripts, and keep the native + // scrollbar whenever the rail is too narrow to replace it. const ResizeObserverCtor = getWindow(host).ResizeObserver; if (ResizeObserverCtor) { - const observer = new ResizeObserverCtor(() => rail.setHostWidth(host.clientWidth)); + const observer = new ResizeObserverCtor(() => { + rail.setHostWidth(host.clientWidth); + this._updateNativeScrollbarHidden(); + }); observer.observe(host); this._enablement.add(toDisposable(() => observer.disconnect())); } rail.setHostWidth(host.clientWidth); + this._updateNativeScrollbarHidden(); + } + + /** + * Detects a deliberate hard/fast scroll from wheel velocity and tells the rail (it only blooms if a + * real scroll movement follows, so flicking against a scroll limit never opens it). Deltas are + * normalized via {@link StandardWheelEvent} so line-mode devices are not stuck below the threshold, + * and the listener is on the capture phase so it is seen before the transcript's ScrollableElement + * consumes the wheel mid-content. + */ + private _registerHardWheelDetector(rail: IPromptTimelineRail): IDisposable { + let wheelAcc = 0; + let wheelWindowStart = 0; + return addDisposableListener(this.widget.domNode, EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + const now = Date.now(); + if (now - wheelWindowStart > WHEEL_WINDOW_MS) { + wheelAcc = 0; + wheelWindowStart = now; + } + wheelAcc += Math.abs(new StandardWheelEvent(e).deltaY); + if (wheelAcc >= HARD_WHEEL_DISTANCE) { + wheelAcc = 0; + rail.notifyHardWheel(); + } + }, { capture: true, passive: true }); + } + + /** Hide the transcript's native scrollbar only while the rail is actually acting as it: enough prompts AND wide enough (below {@link MIN_HOST_WIDTH} the rail hides, so the native slider must stay). */ + private _updateNativeScrollbarHidden(): void { + const active = this._hasEnoughPrompts && this.widget.domNode.clientWidth >= MIN_HOST_WIDTH; + this.widget.domNode.classList.toggle('prompt-timeline-active', active); } // -- Navigation API (used by promptTimelineActions) -- diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 14afc43baf598a..c55ee127bc6419 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -741,6 +741,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.listWidget.scrollHeight; } + get viewportHeight(): number { + return this.listWidget.renderHeight; + } + get attachmentModel(): ChatAttachmentModel { return this.input.attachmentModel; } From ee42cd17648e4a099803b22533de38f07a4ef689 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Wed, 15 Jul 2026 15:46:26 -0700 Subject: [PATCH 07/21] Remove low-usage tools from vscode-general tool set (#326056) * Remove low-usage tools from vscode-general tool set Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tools/clientToolSetsContribution.ts | 4 --- .../tools/toolSetsContribution.test.ts | 29 +++++++++---------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts index c4f4119beae7c2..0e288158e643c7 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts @@ -72,10 +72,6 @@ export class ClientToolSetsContribution extends Disposable implements IWorkbench 'testFailure', 'rename', 'usages', - 'extensions', - 'installExtension', - 'newWorkspace', - 'runCommand', 'toolSearch', ], })); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts index 3ea4ca37c16f30..8b105d7e503742 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts @@ -26,24 +26,21 @@ suite('ToolSetsContribution', () => { return store.add(instaService.createInstance(LanguageModelToolsService)); } - test('ClientToolSetsContribution omits VS Code API from Agent Host tools', () => { + test('ClientToolSetsContribution omits removed tools from vscode-general', () => { const toolsService = createToolsService(); - const toolSearch: IToolData = { - id: 'toolSearch', - modelDescription: 'Search for tools', - displayName: 'Tool Search', - toolReferenceName: 'toolSearch', + const makeTool = (name: string): IToolData => ({ + id: name, + modelDescription: name, + displayName: name, + toolReferenceName: name, source: ToolDataSource.Internal, - }; - const vscodeAPI: IToolData = { - id: 'vscodeAPI', - modelDescription: 'Search VS Code API documentation', - displayName: 'VS Code API', - toolReferenceName: 'vscodeAPI', - source: ToolDataSource.Internal, - }; - store.add(toolsService.registerToolData(toolSearch)); - store.add(toolsService.registerToolData(vscodeAPI)); + }); + const toolSearch = makeTool('toolSearch'); + const removed = ['extensions', 'installExtension', 'newWorkspace', 'runCommand', 'vscodeAPI'].map(makeTool); + for (const tool of [toolSearch, ...removed]) { + store.add(toolsService.registerToolData(tool)); + } + const workspaceService = new class extends mock() { override readonly isSessionsWindow = true; From ed83513cfec34793f6c90c1c9d715805a658e108 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:54:19 +0000 Subject: [PATCH 08/21] Default agents.voice.handsFree to false (#326046) * Initial plan * Set agents.voice.handsFree to false by default Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --- .../contrib/chat/browser/voiceInputDecorations.ts | 2 +- .../agentsVoice/browser/agentsVoice.contribution.ts | 2 +- .../agentsVoice/browser/agentsVoiceWindowService.ts | 2 +- .../chat/browser/voiceClient/voiceSessionController.ts | 8 ++++---- .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts index 070b0447126973..ff65598dcb2720 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts @@ -132,7 +132,7 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi } if (visible.length === 0 || !showTranscript) { - const handsFree = configurationService.getValue('agents.voice.handsFree') !== false; + const handsFree = configurationService.getValue('agents.voice.handsFree') === true; if (!showTranscript && voiceState === 'listening') { // Transcript is disabled: surface a minimal "Listening..." overlay // while listening so the user has feedback. Cleared in any other state. diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 96b146c3766fd3..c91f76d88af7fe 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -503,7 +503,7 @@ configurationRegistry.registerConfiguration({ 'agents.voice.handsFree': { type: 'boolean', markdownDescription: nls.localize('agents.voice.handsFree', "When enabled, voice mode automatically re-enters listening after the assistant finishes speaking, so you can hold a hands-free back-and-forth conversation. When disabled, you start each turn manually. This controls only the auto-listen loop; how a turn ends is controlled by {0} and {1}.", '`#agents.voice.turn.silenceMs#`', '`#agents.voice.turn.stopPhrases#`'), - default: true, + default: false, scope: ConfigurationScope.APPLICATION, }, 'agents.voice.turn.silenceMs': { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index ae88bff0a3a29e..0e54770fd60f34 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -148,7 +148,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), - hideDisconnect: this.configurationService.getValue('agents.voice.handsFree') !== false, + hideDisconnect: this.configurationService.getValue('agents.voice.handsFree') === true, connect: () => { // Connecting from any surface marks onboarding as completed so // the main panel drops it too. diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 7a25bbb6f33559..58379db3bd2ad5 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -1710,10 +1710,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } private _isHandsFreeEnabled(): boolean { - // Default-on: treat only an explicit `false` as disabled so an - // unresolved/undefined value still enables hands-free (matches the - // `handsFree` default and the window-service `!== false` check). - return this.configurationService.getValue('agents.voice.handsFree') !== false; + // Default-off: hands-free auto-listen is opt-in, so only an explicit + // `true` enables it. An unresolved/undefined value resolves to the + // `handsFree` default (`false`) and stays disabled. + return this.configurationService.getValue('agents.voice.handsFree') === true; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 761ffcfc65393f..990a77cdae2ca8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -581,7 +581,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { - const handsFree = this.configurationService.getValue('agents.voice.handsFree') !== false; + const handsFree = this.configurationService.getValue('agents.voice.handsFree') === true; if (!showTranscript && voiceState === 'listening') { // Transcript is disabled: surface a minimal "Listening..." overlay // while listening so the user has feedback. Cleared in any other state. From d4ea5d4a2c4b6dfc7d9006144ef3b6b2b30c2531 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:58:18 -0700 Subject: [PATCH 09/21] Add edit tracker characterization tests (#326062) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../helpers/documentWithAnnotatedEdits.ts | 3 +- .../browser/telemetry/arcTelemetryReporter.ts | 4 +- .../browser/telemetry/arcTelemetrySender.ts | 6 +- .../telemetry/editSourceTrackingImpl.ts | 54 ++-- .../browser/telemetry/scmAdapter.ts | 10 +- .../documentWithAnnotatedEdits.test.ts | 146 +++++++++++ .../test/browser/editSourceCategories.test.ts | 86 +++++++ .../browser/editSourceTrackingImpl.test.ts | 231 ++++++++++++++++++ .../test/browser/editTracker.test.ts | 140 +++++++++++ 9 files changed, 642 insertions(+), 38 deletions(-) create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts index c059121d538491..db0153cc1dc94c 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts @@ -146,7 +146,7 @@ export class InlineSuggestEditSource extends EditSourceBase { public readonly type: 'word' | 'line' | undefined, ) { super(); } - override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.type}`; } + override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.providerId}/${this.type}`; } public getColor(): string { return '#00ff0033'; } } @@ -330,4 +330,3 @@ export function createDocWithJustReason(docWithAnnotatedEdits: IDocumentWithAnno }; return docWithJustReason; } - diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts index 387563fbd4af28..eb5c36319f4664 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts @@ -9,7 +9,7 @@ import { BaseStringEdit } from '../../../../../editor/common/core/edits/stringEd import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ArcTracker } from '../../common/arcTracker.js'; -import type { ScmRepoAdapter } from './scmAdapter.js'; +import type { IScmRepoAdapter } from './scmAdapter.js'; export class ArcTelemetryReporter extends Disposable { private readonly _arcTracker; @@ -22,7 +22,7 @@ export class ArcTelemetryReporter extends Disposable { private readonly _documentValueBeforeTrackedEdit: StringText, private readonly _document: { value: IObservableWithChange }, // _markedEdits -> document.value - private readonly _gitRepo: IObservable, + private readonly _gitRepo: IObservable, private readonly _trackedEdit: BaseStringEdit, private readonly _sendTelemetryEvent: (res: ArcTelemetryReporterData) => void, private readonly _dispose: () => void, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts index a0abb90038473d..263aad985d0055 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts @@ -11,7 +11,7 @@ import { EditDeltaInfo, EditSuggestionId, ITextModelEditSourceMetadata } from '. import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditSourceData, IDocumentWithAnnotatedEdits, createDocWithJustReason } from '../helpers/documentWithAnnotatedEdits.js'; import { IAiEditTelemetryService } from './aiEditTelemetry/aiEditTelemetryService.js'; -import type { ScmRepoAdapter } from './scmAdapter.js'; +import type { IScmRepoAdapter } from './scmAdapter.js'; import { forwardToChannelIf, isCopilotLikeExtension } from '../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; import { ProviderId } from '../../../../../editor/common/languages.js'; import { ArcTelemetryReporter } from './arcTelemetryReporter.js'; @@ -20,7 +20,7 @@ import { IRandomService } from '../randomService.js'; export class EditTelemetryReportInlineEditArcSender extends Disposable { constructor( docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, - scmRepoBridge: IObservable, + scmRepoBridge: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); @@ -154,7 +154,7 @@ export class CreateSuggestionIdForChatOrInlineChatCaller extends Disposable { export class EditTelemetryReportEditArcForChatOrInlineChatSender extends Disposable { constructor( docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, - scmRepoBridge: IObservable, + scmRepoBridge: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IRandomService private readonly _randomService: IRandomService, ) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 55fb2fdf16f967..ff534ab884a5ef 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -15,12 +15,29 @@ import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArc import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; -import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; +import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; +export type EditTelemetryCategory = 'nes' | 'inlineCompletionsCopilot' | 'inlineCompletionsNES' | 'inlineCompletionsOther' | 'otherAI' | 'user' | 'ide' | 'external' | 'unknown'; + +export function getEditTelemetryCategory(source: EditSource): EditTelemetryCategory { + if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; } + + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; } + if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; } + + if (source.category === 'ai') { return 'otherAI'; } + if (source.category === 'user') { return 'user'; } + if (source.category === 'ide') { return 'ide'; } + if (source.category === 'external') { return 'external'; } + return 'unknown'; +} + export class EditSourceTrackingImpl extends Disposable { public readonly docsState; private readonly _states; @@ -47,7 +64,7 @@ class TrackedDocumentInfo extends Disposable { public readonly windowedTracker: IObservable | undefined>; public readonly windowedFocusTracker: IObservable | undefined>; - private readonly _repo: IObservable; + private readonly _repo: IObservable; constructor( private readonly _doc: AnnotatedDocument, @@ -182,21 +199,17 @@ class TrackedDocumentInfo extends Disposable { const statsUuid = this._randomService.generateUuid(); const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); - const entries = Object.entries(sums).filter(([key, value]) => value !== undefined); - entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator))); - entries.length = mode === 'longterm' ? 30 : 10; - for (const key of keys) { if (!sums[key]) { sums[key] = 0; } } + const entries = Object.entries(sums) + .filter((entry): entry is [string, number] => entry[1] !== undefined) + .sort(reverseOrder(compareBy(([, value]) => value, numberComparator))) + .slice(0, mode === 'longterm' ? 30 : 10); - for (const [key, value] of Object.entries(sums)) { - if (value === undefined) { - continue; - } - + for (const [key, value] of entries) { const repr = t.getRepresentative(key)!; const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); @@ -321,24 +334,7 @@ class TrackedDocumentInfo extends Disposable { } getTelemetryData(ranges: readonly TrackedEdit[]) { - const getEditCategory = (source: EditSource) => { - if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; } - - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; } - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; } - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; } - if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; } - - if (source.category === 'ai') { return 'otherAI'; } - if (source.category === 'user') { return 'user'; } - if (source.category === 'ide') { return 'ide'; } - if (source.category === 'external') { return 'external'; } - if (source.category === 'unknown') { return 'unknown'; } - - return 'unknown'; - }; - - const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source)); + const sums = sumByCategory(ranges, r => r.range.length, r => getEditTelemetryCategory(r.source)); const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length); return { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts index 02b8d245137375..98738835158b97 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts @@ -20,7 +20,7 @@ export class ScmAdapter { this._reposChangedSignal = observableSignalFromEvent(this, Event.any(this._scmService.onDidAddRepository, this._scmService.onDidRemoveRepository)); } - public getRepo(uri: URI, reader: IReader | undefined): ScmRepoAdapter | undefined { + public getRepo(uri: URI, reader: IReader | undefined): IScmRepoAdapter | undefined { this._reposChangedSignal.read(reader); const repo = this._scmService.getRepository(uri); if (!repo) { @@ -30,7 +30,13 @@ export class ScmAdapter { } } -export class ScmRepoAdapter { +export interface IScmRepoAdapter { + readonly headBranchNameObs: IObservable; + readonly headCommitHashObs: IObservable; + isIgnored(uri: URI): Promise; +} + +export class ScmRepoAdapter implements IScmRepoAdapter { public readonly headBranchNameObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.name); public readonly headCommitHashObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.revision); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts new file mode 100644 index 00000000000000..c9ea85b97961c6 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { CombineStreamedChanges, DiffService, EditSourceData, IDocumentWithAnnotatedEdits, MinimizeEditsProcessor } from '../../browser/helpers/documentWithAnnotatedEdits.js'; + +suite('Documents with Annotated Edits', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('collapses streamed chat edits into one diff', () => runWithFakedTimers({}, async () => { + const context = setup(''); + const source = chatEdit(); + await timeout(0); + + context.document.apply(StringEdit.insert(0, 'a'), source); + await timeout(500); + context.document.apply(StringEdit.insert(1, 'b'), source); + await timeout(1100); + + assert.deepStrictEqual(context.changes, [{ + value: 'ab', + source: 'ai/chat/sidebar', + replacements: [{ start: 0, endExclusive: 0, newText: 'ab' }], + }]); + context.disposables.dispose(); + })); + + test('preserves ordering when a user edit interrupts streamed chat edits', () => runWithFakedTimers({}, async () => { + const context = setup(''); + await timeout(0); + + context.document.apply(StringEdit.insert(0, 'a'), chatEdit()); + await timeout(500); + context.document.apply(StringEdit.insert(1, 'U'), EditSources.cursor({ kind: 'type' })); + await timeout(1100); + + assert.deepStrictEqual(context.changes, [ + { + value: 'a', + source: 'ai/chat/sidebar', + replacements: [{ start: 0, endExclusive: 0, newText: 'a' }], + }, + { + value: 'aU', + source: 'user', + replacements: [{ start: 1, endExclusive: 1, newText: 'U' }], + }, + ]); + context.disposables.dispose(); + })); + + test('minimizes common prefixes and suffixes', () => { + const disposables = new DisposableStore(); + const document = disposables.add(new TestSourceDocument('hello world')); + const minimized = disposables.add(new MinimizeEditsProcessor(document)); + const changes: Array<{ value: string; replacements: Array<{ start: number; endExclusive: number; newText: string }> }> = []; + disposables.add(runOnChange(minimized.value, (value, _previous, edits) => { + const edit = AnnotatedStringEdit.compose(edits.map(change => change.edit)); + changes.push({ + value: value.value, + replacements: edit.replacements.map(replacement => ({ + start: replacement.replaceRange.start, + endExclusive: replacement.replaceRange.endExclusive, + newText: replacement.newText, + })), + }); + })); + + document.apply(StringEdit.replace(OffsetRange.ofLength(11), 'hello brave world'), chatEdit()); + + assert.deepStrictEqual(changes, [{ + value: 'hello brave world', + replacements: [{ start: 5, endExclusive: 5, newText: ' brave' }], + }]); + disposables.dispose(); + }); +}); + +function setup(initialValue: string) { + const disposables = new DisposableStore(); + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stubInstance(DiffService, { + computeDiff: async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + }); + const document = disposables.add(new TestSourceDocument(initialValue)); + const combined = disposables.add(instantiationService.createInstance(CombineStreamedChanges, document)); + const changes: Array<{ value: string; source: string; replacements: Array<{ start: number; endExclusive: number; newText: string }> }> = []; + disposables.add(runOnChange(combined.value, (value, _previous, edits) => { + const edit = AnnotatedStringEdit.compose(edits.map(change => change.edit)); + changes.push({ + value: value.value, + source: edit.replacements[0]?.data.source.toString(), + replacements: edit.replacements.map(replacement => ({ + start: replacement.replaceRange.start, + endExclusive: replacement.replaceRange.endExclusive, + newText: replacement.newText, + })), + }); + })); + return { disposables, document, changes }; +} + +class TestSourceDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialValue: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialValue)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +function chatEdit(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: 'session-1', + requestId: 'request-1', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts new file mode 100644 index 00000000000000..e5673e3df2c4b0 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ProviderId } from '../../../../../editor/common/languages.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditSourceBase } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { getEditTelemetryCategory } from '../../browser/telemetry/editSourceTrackingImpl.js'; + +suite('Edit Telemetry Source Categories', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps every edit source category', () => { + const sources = { + chat: EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: undefined, + requestId: undefined, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }), + copilotCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-1', + languageId: 'typescript', + providerId: new ProviderId('github.copilot', '1.0.0', 'completions'), + correlationId: undefined, + }), + copilotChatCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-2', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'completions'), + correlationId: undefined, + }), + nes: EditSources.inlineCompletionAccept({ + nes: true, + requestUuid: 'request-3', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'nes'), + correlationId: undefined, + }), + inlineNesProvider: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-4', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'nes'), + correlationId: undefined, + }), + otherCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-5', + languageId: 'typescript', + providerId: new ProviderId('other.extension', '1.0.0', 'other'), + correlationId: undefined, + }), + user: EditSources.cursor({ kind: 'type' }), + snippet: EditSources.snippet(), + format: EditSources.unknown({ name: 'formatEditsCommand' }), + external: EditSources.reloadFromDisk(), + unknown: EditSources.unknown({}), + }; + + assert.deepStrictEqual(Object.fromEntries(Object.entries(sources).map(([key, source]) => [ + key, + getEditTelemetryCategory(EditSourceBase.create(source)), + ])), { + chat: 'otherAI', + copilotCompletion: 'inlineCompletionsCopilot', + copilotChatCompletion: 'inlineCompletionsCopilot', + nes: 'nes', + inlineNesProvider: 'inlineCompletionsNES', + otherCompletion: 'inlineCompletionsOther', + user: 'user', + snippet: 'ide', + format: 'ide', + external: 'external', + unknown: 'unknown', + }); + }); +}); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts new file mode 100644 index 00000000000000..f84b57dab64101 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js'; +import { AnnotatedDocuments, UriVisibilityProvider } from '../../browser/helpers/annotatedDocuments.js'; +import { DiffService } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { IAiEditTelemetryService } from '../../browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { EditSourceTrackingImpl } from '../../browser/telemetry/editSourceTrackingImpl.js'; +import { IScmRepoAdapter, ScmAdapter } from '../../browser/telemetry/scmAdapter.js'; +import { IRandomService } from '../../browser/randomService.js'; +import { MutableObservableWorkspace } from './editTelemetry.test.js'; + +suite('Edit Source Tracking Windows', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('flushes and recreates the long-term tracker on hash and branch changes', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + context.headHash.set('hash-2', undefined); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('alpha'), 'beta', chatEdit('request-2'))); + await timeout(1500); + context.branch.set('feature', undefined); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + modifiedCount: event.modifiedCount, + deltaModifiedCount: event.deltaModifiedCount, + })), [ + { trigger: 'hashChange', requestId: 'request-1', modifiedCount: 5, deltaModifiedCount: 5 }, + { trigger: 'branchChange', requestId: 'request-2', modifiedCount: 3, deltaModifiedCount: 3 }, + ]); + + context.disposables.dispose(); + })); + + test('flushes the long-term tracker when the document closes', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + context.document.dispose(); + await timeout(0); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + })), [{ trigger: 'closed', requestId: 'request-1' }]); + + context.disposables.dispose(); + })); + + test('flushes and recreates the long-term tracker after ten hours', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + await timeout(10 * 60 * 60 * 1000); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('alpha'), 'beta', chatEdit('request-2'))); + await timeout(1500); + context.headHash.set('hash-2', undefined); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + })), [ + { trigger: '10hours', requestId: 'request-1' }, + { trigger: 'hashChange', requestId: 'request-2' }, + ]); + + context.disposables.dispose(); + })); + + test('emits only the top thirty long-term sources by retained count', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + for (let i = 1; i <= 31; i++) { + context.document.applyEdit(StringEditWithReason.replace( + OffsetRange.emptyAt(context.document.value.get().value.length), + 'x'.repeat(i), + EditSources.unknown({ name: `source-${i}` }), + )); + } + await timeout(10); + context.headHash.set('hash-2', undefined); + + assert.deepStrictEqual({ + count: context.details.length, + first: context.details[0].sourceKey, + last: context.details.at(-1)?.sourceKey, + containsSmallest: context.details.some(event => event.sourceKey === 'source:unknown-name:source-1'), + }, { + count: 30, + first: 'source:unknown-name:source-31', + last: 'source:unknown-name:source-2', + containsSmallest: false, + }); + + context.disposables.dispose(); + })); + + test('starts after first visibility and keeps only the long-term tracker while hidden', () => runWithFakedTimers({}, async () => { + const visible = observableValue('visible', false); + const context = setup(visible); + await timeout(10); + + assert.strictEqual(context.impl.docsState.get().size, 0); + + visible.set(true, undefined); + const visibleState = context.impl.docsState.get().get(context.document); + if (!visibleState) { + throw new Error('Expected visible document state'); + } + assert.ok(visibleState.longtermTracker.get()); + const firstWindowedTracker = visibleState.windowedTracker.get(); + assert.ok(firstWindowedTracker); + assert.ok(visibleState.windowedFocusTracker.get()); + + visible.set(false, undefined); + const hiddenState = context.impl.docsState.get().get(context.document); + if (!hiddenState) { + throw new Error('Expected hidden document state'); + } + assert.ok(hiddenState.longtermTracker.get()); + assert.strictEqual(hiddenState.windowedTracker.get(), undefined); + assert.strictEqual(hiddenState.windowedFocusTracker.get(), undefined); + + visible.set(true, undefined); + const visibleAgainState = context.impl.docsState.get().get(context.document); + if (!visibleAgainState) { + throw new Error('Expected visible document state after reopening'); + } + assert.ok(visibleAgainState.windowedTracker.get()); + assert.notStrictEqual(visibleAgainState.windowedTracker.get(), firstWindowedTracker); + + context.disposables.dispose(); + })); +}); + +function setup(visible: ISettableObservable = observableValue('visible', true)) { + const disposables = new DisposableStore(); + const headHash = observableValue('headHash', 'hash-1'); + const branch = observableValue('branch', 'main'); + const repo = { + headCommitHashObs: headHash, + headBranchNameObs: branch, + isIgnored: async () => false, + } satisfies IScmRepoAdapter; + const details: Array<{ sourceKey: string; trigger: string; requestId: string | undefined; modifiedCount: number; deltaModifiedCount: number }> = []; + let uuid = 0; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stub(ITelemetryService, { + publicLog2(eventName, data) { + const eventData = data as { mode?: string } | undefined; + if (eventName === 'editTelemetry.editSources.details' && eventData?.mode === 'longterm') { + details.push(data as typeof details[number]); + } + }, + }); + instantiationService.stubInstance(DiffService, { computeDiff: async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced') }); + instantiationService.stubInstance(ScmAdapter, { getRepo: () => repo }); + instantiationService.stubInstance(UriVisibilityProvider, { isVisible: (_uri, reader) => visible.read(reader) }); + instantiationService.stub(IRandomService, { + _serviceBrand: undefined, + generateUuid: () => `stats-${++uuid}`, + generatePrefixedUuid: namespace => `${namespace}-${++uuid}`, + }); + instantiationService.stub(IUserAttentionService, { + _serviceBrand: undefined, + isVsCodeFocused: constObservable(true), + isUserActive: constObservable(true), + hasUserAttention: constObservable(true), + totalFocusTimeMs: 0, + fireAfterGivenFocusTimePassed: () => Disposable.None, + }); + instantiationService.stub(IAiEditTelemetryService, { + _serviceBrand: undefined, + createSuggestionId: () => EditSuggestionId.newId(() => 'sgt-test'), + handleCodeAccepted: () => { }, + handleCodeRejected: () => { }, + }); + instantiationService.stub(ILogService, new NullLogService()); + + const workspace = new MutableObservableWorkspace(); + const annotatedDocuments = disposables.add(new AnnotatedDocuments(workspace, instantiationService)); + const impl = disposables.add(new EditSourceTrackingImpl(constObservable(true), annotatedDocuments, instantiationService)); + const document = disposables.add(workspace.createDocument({ + uri: URI.file('C:\\repo\\file.ts'), + initialValue: 'hello', + languageId: 'typescript', + })); + + return { disposables, document, details, headHash, branch, impl }; +} + +function chatEdit(requestId: string) { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: 'session-1', + requestId, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts new file mode 100644 index 00000000000000..8ef814c7b4267d --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; + +suite('DocumentEditSourceTracker', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('ignores an initial external edit', () => { + const document = disposables.add(new TestAnnotatedDocument('initial')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + + document.apply(StringEdit.replace(OffsetRange.ofLength(7), 'external'), EditSources.reloadFromDisk()); + + assert.deepStrictEqual(snapshot(tracker), []); + }); + + test('applies queued external edits before the next attributed edit', () => { + const document = disposables.add(new TestAnnotatedDocument('')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + const ai = chatEditSource('gpt-5', 'request-1'); + const user = EditSources.cursor({ kind: 'type' }); + + document.apply(StringEdit.insert(0, 'abcdef'), ai); + document.apply(StringEdit.delete(new OffsetRange(2, 4)), EditSources.reloadFromDisk()); + + assert.deepStrictEqual(snapshot(tracker), [{ + key: ai.toKey(1), + delta: 6, + retained: 6, + requestId: 'request-1', + }]); + + document.apply(StringEdit.insert(4, 'X'), user); + + assert.deepStrictEqual(snapshot(tracker), [ + { + key: ai.toKey(1), + delta: 6, + retained: 4, + requestId: 'request-1', + }, + { + key: 'source:cursor-kind:type', + delta: 1, + retained: 1, + requestId: undefined, + }, + { + key: 'source:reloadFromDisk', + delta: 0, + retained: 0, + requestId: undefined, + }, + ]); + }); + + test('joins level-one keys but keeps distinct model ids separate', () => { + const document = disposables.add(new TestAnnotatedDocument('')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + const gptFirst = chatEditSource('gpt-5', 'request-1'); + const gptSecond = chatEditSource('gpt-5', 'request-2'); + const claude = chatEditSource('claude-sonnet', 'request-3'); + + document.apply(StringEdit.insert(0, 'one'), gptFirst); + document.apply(StringEdit.insert(3, 'two'), gptSecond); + document.apply(StringEdit.insert(6, 'three'), claude); + + assert.deepStrictEqual(snapshot(tracker), [ + { + key: claude.toKey(1), + delta: 5, + retained: 5, + requestId: 'request-3', + }, + { + key: gptFirst.toKey(1), + delta: 6, + retained: 6, + requestId: 'request-1', + }, + ]); + assert.strictEqual(gptFirst.toKey(1), gptSecond.toKey(1)); + }); +}); + +class TestAnnotatedDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialValue: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialValue)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +function chatEditSource(modelId: string, requestId: string): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId, + sessionId: 'session-1', + requestId, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} + +function snapshot(tracker: DocumentEditSourceTracker): Array<{ key: string; delta: number; retained: number; requestId: string | undefined }> { + const retained = new Map(); + for (const range of tracker.getTrackedRanges()) { + retained.set(range.sourceKey, (retained.get(range.sourceKey) ?? 0) + range.range.length); + } + return tracker.getAllKeys().map(key => ({ + key, + delta: tracker.getTotalInsertedCharactersCount(key), + retained: retained.get(key) ?? 0, + requestId: tracker.getRepresentative(key)?.props.$$requestId, + })).sort((a, b) => a.key.localeCompare(b.key)); +} From b7d0a0bb1c75ffcb5be780fdfe5970a5c2482aec Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 15 Jul 2026 16:08:06 -0700 Subject: [PATCH 10/21] Fix remote Agent Host dependency packaging (#326019) * Fix remote Agent Host dependencies Include fs-copyfile in REH packages and verify that startup imports are declared in the remote manifest. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Smoke test packaged remote Agent Host Launch the packaged Agent Host with its bundled Node after native REH builds and require it to bind a WebSocket listener. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/smokeTestAgentHost.ts | 129 +++++++++++++++++ .../steps/product-build-darwin-compile.yml | 4 + .../steps/product-build-linux-compile.yml | 4 + .../steps/product-build-win32-compile.yml | 4 + build/lib/test/agentHostDependencies.test.ts | 136 ++++++++++++++++++ remote/package-lock.json | 20 +++ remote/package.json | 2 + 7 files changed, 299 insertions(+) create mode 100644 build/azure-pipelines/common/smokeTestAgentHost.ts create mode 100644 build/lib/test/agentHostDependencies.test.ts diff --git a/build/azure-pipelines/common/smokeTestAgentHost.ts b/build/azure-pipelines/common/smokeTestAgentHost.ts new file mode 100644 index 00000000000000..bb9fd3a888fb61 --- /dev/null +++ b/build/azure-pipelines/common/smokeTestAgentHost.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ChildProcess, fork } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setTimeout as delay } from 'timers/promises'; + +const startupTimeoutMs = 30_000; +const shutdownTimeoutMs = 5_000; +const readyPattern = /Agent host server listening on \S+/; + +async function main(serverRoot: string | undefined): Promise { + if (!serverRoot) { + throw new Error('Usage: node smokeTestAgentHost.ts '); + } + + const nodePath = path.join(serverRoot, process.platform === 'win32' ? 'node.exe' : 'node'); + const bootstrapPath = path.join(serverRoot, 'out', 'bootstrap-fork.js'); + for (const requiredPath of [nodePath, bootstrapPath]) { + if (!fs.existsSync(requiredPath)) { + throw new Error(`Packaged Agent Host dependency not found: ${requiredPath}`); + } + } + + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-agent-host-smoke-')); + const logsPath = path.join(temporaryRoot, 'logs'); + const userDataPath = path.join(temporaryRoot, 'user-data'); + fs.mkdirSync(logsPath, { recursive: true }); + fs.mkdirSync(userDataPath, { recursive: true }); + + const child = fork(bootstrapPath, [ + '--type=agentHost', + '--logsPath', logsPath, + '--user-data-dir', userDataPath, + '--disable-telemetry', + ], { + cwd: serverRoot, + env: { + ...process.env, + VSCODE_DEV: undefined, + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_AGENT_HOST_CONNECTION_TOKEN: undefined, + VSCODE_AGENT_HOST_HOST: '127.0.0.1', + VSCODE_AGENT_HOST_PORT: '0', + VSCODE_AGENT_HOST_SOCKET_PATH: undefined, + VSCODE_NLS_CONFIG: JSON.stringify({ + userLocale: 'en', + osLocale: 'en', + resolvedLanguage: 'en', + defaultMessagesFile: path.join(serverRoot, 'out', 'nls.messages.json'), + }), + VSCODE_PIPE_LOGGING: 'false', + VSCODE_VERBOSE_LOGGING: 'false', + }, + execPath: nodePath, + silent: true, + }); + + try { + await waitForReady(child); + console.log('Packaged Agent Host started successfully.'); + } finally { + await stopProcess(child); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function waitForReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let output = ''; + let settled = false; + + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + const appendOutput = (data: Buffer) => { + const text = data.toString(); + process.stdout.write(text); + output = `${output}${text}`.slice(-64 * 1024); + if (readyPattern.test(output)) { + finish(); + } + }; + + child.stdout?.on('data', appendOutput); + child.stderr?.on('data', appendOutput); + child.once('error', error => finish(error)); + child.once('exit', (code, signal) => { + finish(new Error(`Packaged Agent Host exited before becoming ready (code: ${code}, signal: ${signal}).\n${output}`)); + }); + + const timeout = setTimeout(() => { + finish(new Error(`Timed out after ${startupTimeoutMs}ms waiting for the packaged Agent Host to start.\n${output}`)); + }, startupTimeoutMs); + }); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + const exited = new Promise(resolve => child.once('exit', () => resolve())); + child.kill(); + await Promise.race([exited, delay(shutdownTimeoutMs)]); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await exited; + } +} + +main(process.argv[2]).catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 3b1d0b2f8feb3d..39f1c1cffd8a1c 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -200,6 +200,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + - script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - script: | set -e npm run gulp vscode-reh-web-darwin-$(VSCODE_ARCH)-min-ci diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 620ef2088c65a1..716983d22b5043 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -290,6 +290,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - script: | set -e npm run gulp vscode-reh-web-linux-$(VSCODE_ARCH)-min-ci diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index f61f9fc904a3f2..44248830c94854 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -229,6 +229,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - powershell: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)\vscode-server-win32-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" diff --git a/build/lib/test/agentHostDependencies.test.ts b/build/lib/test/agentHostDependencies.test.ts new file mode 100644 index 00000000000000..b0731de0e52f07 --- /dev/null +++ b/build/lib/test/agentHostDependencies.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import fs from 'fs'; +import { builtinModules } from 'module'; +import path from 'path'; +import { suite, test } from 'node:test'; +import * as ts from 'typescript'; + +const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); +const agentHostEntryPoints = [ + 'src/vs/platform/agentHost/node/agentHostMain.ts', + 'src/vs/platform/agentHost/node/agentHostServerMain.ts', +]; + +suite('Agent Host dependencies', () => { + test('runtime packages are included in the remote server', () => { + const remotePackageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, 'remote/package.json'), 'utf8')) as { + dependencies?: Record; + optionalDependencies?: Record; + }; + const packagedDependencies = new Set([ + ...Object.keys(remotePackageJson.dependencies ?? {}), + ...Object.keys(remotePackageJson.optionalDependencies ?? {}), + ]); + const runtimeImports = collectRuntimePackageImports( + agentHostEntryPoints.map(entryPoint => path.join(repositoryRoot, entryPoint)) + ); + const missingDependencies = [...runtimeImports] + .filter(([packageName]) => !packagedDependencies.has(packageName)) + .map(([packageName, importers]) => `${packageName}: ${[...importers].sort().map(importer => path.relative(repositoryRoot, importer)).join(', ')}`) + .sort(); + + assert.deepStrictEqual(missingDependencies, []); + }); +}); + +function collectRuntimePackageImports(entryPoints: readonly string[]): Map> { + const pendingFiles = [...entryPoints]; + const visitedFiles = new Set(); + const packageImports = new Map>(); + const builtInModules = new Set([...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)]); + + while (pendingFiles.length > 0) { + const file = pendingFiles.pop()!; + if (visitedFiles.has(file)) { + continue; + } + visitedFiles.add(file); + + const sourceFile = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + for (const moduleSpecifier of getRuntimeModuleSpecifiers(sourceFile)) { + if (moduleSpecifier.startsWith('.')) { + const importedFile = resolveSourceImport(file, moduleSpecifier); + if (importedFile) { + pendingFiles.push(importedFile); + } + continue; + } + + if (builtInModules.has(moduleSpecifier)) { + continue; + } + + const packageName = getPackageName(moduleSpecifier); + let importers = packageImports.get(packageName); + if (!importers) { + importers = new Set(); + packageImports.set(packageName, importers); + } + importers.add(file); + } + } + + return packageImports; +} + +function getRuntimeModuleSpecifiers(sourceFile: ts.SourceFile): string[] { + const moduleSpecifiers: string[] = []; + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && isRuntimeImport(statement)) { + moduleSpecifiers.push(statement.moduleSpecifier.text); + } else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { + moduleSpecifiers.push(statement.moduleSpecifier.text); + } + } + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && (node.expression.text === 'require' || node.expression.text === 'nativeRequire') + && node.arguments.length === 1 + && ts.isStringLiteral(node.arguments[0]) + ) { + moduleSpecifiers.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + return moduleSpecifiers; +} + +function isRuntimeImport(statement: ts.ImportDeclaration): boolean { + const clause = statement.importClause; + if (!clause) { + return true; + } + if (clause.isTypeOnly) { + return false; + } + if (clause.name || !clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) { + return true; + } + return clause.namedBindings.elements.some(element => !element.isTypeOnly); +} + +function resolveSourceImport(importer: string, moduleSpecifier: string): string | undefined { + const unresolvedPath = path.resolve(path.dirname(importer), moduleSpecifier); + const candidates = [ + unresolvedPath, + unresolvedPath.replace(/\.js$/, '.ts'), + unresolvedPath.replace(/\.js$/, '.tsx'), + path.join(unresolvedPath, 'index.ts'), + ]; + return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); +} + +function getPackageName(moduleSpecifier: string): string { + const segments = moduleSpecifier.split('/'); + return moduleSpecifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} diff --git a/remote/package-lock.json b/remote/package-lock.json index 47a839233b83f5..02db175e51e9da 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -16,6 +16,7 @@ "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", + "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.44.0", @@ -644,6 +645,25 @@ "uuid": "^14.0.0" } }, + "node_modules/@vscode/fs-copyfile": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vscode/fs-copyfile/-/fs-copyfile-2.0.0.tgz", + "integrity": "sha512-ARb4+9rN905WjJtQ2mSBG/q4pjJkSRun/MkfCeRkk7h/5J8w4vd18NCePFJ/ZucIwXx/7mr9T6nz9Vtt1tk7hg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">=22.6.0" + } + }, + "node_modules/@vscode/fs-copyfile/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", diff --git a/remote/package.json b/remote/package.json index c65575a4dacd33..aed3d8405db9c8 100644 --- a/remote/package.json +++ b/remote/package.json @@ -11,6 +11,7 @@ "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", + "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.44.0", @@ -65,6 +66,7 @@ "node-pty@1.2.0-beta.13": true, "@parcel/watcher@2.5.6": true, "@vscode/deviceid@0.1.5": true, + "@vscode/fs-copyfile@2.0.0": true, "@vscode/native-watchdog@1.4.6": true, "@vscode/spdlog@0.15.8": true, "@vscode/windows-registry@1.2.0": true, From 7385ad14d553f153c97da3d25427d23ae5bcc076 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:19:18 -0700 Subject: [PATCH 11/21] Track Agent Host edit sources Attribute Agent Host file edits in a workbench-side synthetic tracker and emit scoped long-term edit source details after disk reconciliation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/editor/common/textModelEditSource.ts | 6 + .../telemetry/agentHostEditSourceTracking.ts | 455 ++++++++++++++++++ .../browser/telemetry/editSourceTelemetry.ts | 59 +++ .../telemetry/editSourceTrackingFeature.ts | 2 + .../telemetry/editSourceTrackingImpl.ts | 51 +- .../browser/telemetry/editTracker.ts | 9 + .../agentHostEditSourceTracking.test.ts | 106 ++++ 7 files changed, 642 insertions(+), 46 deletions(-) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index d0ba71d2c355a5..3973745175c5c2 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -108,12 +108,18 @@ export const EditSources = { mode: string | undefined; extensionId: VersionedExtensionId | undefined; codeBlockSuggestionId: EditSuggestionId | undefined; + harness?: string; + origin?: string; + trackingScope?: string; }) { return createEditSource({ source: 'Chat.applyEdits', $modelId: avoidPathRedaction(data.modelId), $extensionId: data.extensionId?.extensionId, $extensionVersion: data.extensionId?.version, + $harness: data.harness, + $origin: data.origin, + $trackingScope: data.trackingScope, $$languageId: data.languageId, $$sessionId: data.sessionId, $$requestId: data.requestId, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts new file mode 100644 index 00000000000000..89b46230263566 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -0,0 +1,455 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { extname } from '../../../../../base/common/path.js'; +import { autorun, derived, IObservable, IObservableWithChange, IReader, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/protocol/common/actions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ToolResultContentType, type ToolResultFileEditContent } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ISCMService } from '../../../scm/common/scm.js'; +import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; +import { IRandomService } from '../randomService.js'; +import { DocumentEditSourceTracker } from './editTracker.js'; +import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; + +const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; +const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; + +type ComputeDiff = (original: string, modified: string) => Promise; +type GetRepo = (resource: URI, reader: IReader) => ScmRepoAdapter | undefined; +type SendDetails = (data: IEditSourcesDetailsTelemetryData, forwardToGitHub: boolean) => void; + +/** + * An in-memory document stream containing only Agent Host edits and reconciliation edits. + */ +class AgentHostSyntheticDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialText: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialText)); + } + + get text(): string { + return this._value.get().value; + } + + async applyTransition(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { + if (this.text !== beforeText) { + await this._apply(this.text, beforeText, EditSources.reloadFromDisk(), computeDiff); + } + await this._apply(beforeText, afterText, source, computeDiff); + } + + async reconcile(text: string, computeDiff: ComputeDiff): Promise { + await this._apply(this.text, text, EditSources.reloadFromDisk(), computeDiff); + } + + private async _apply(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { + if (beforeText === afterText) { + return; + } + const data = new EditSourceData(source).toEditSourceData(); + const edit = (await computeDiff(beforeText, afterText)).mapData(() => data); + this._value.set(new StringText(afterText), undefined, { edit }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +/** + * Tracks long-term Agent Host AI attribution for one file. + */ +export class AgentHostTrackedFile extends Disposable { + private readonly _document: AgentHostSyntheticDocument; + private readonly _tracker = this._register(new MutableDisposable()); + private readonly _resource: ISettableObservable; + private readonly _repo; + private _languageId = 'plaintext'; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + resource: URI, + initialText: string, + private readonly _readCurrentText: (resource: URI) => Promise, + private readonly _computeDiff: ComputeDiff, + getRepo: GetRepo, + private readonly _generateUuid: () => string, + private readonly _sendDetails: SendDetails, + private readonly _logService: ILogService, + private readonly _onDidExpire: () => void, + ) { + super(); + this._resource = observableValue(this, resource); + this._document = this._register(new AgentHostSyntheticDocument(initialText)); + this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); + this._repo = derived(this, reader => getRepo(this._resource.read(reader), reader)); + + this._register(autorun(reader => { + const repo = this._repo.read(reader); + if (!repo) { + return; + } + reader.store.add(runOnChange(repo.headCommitHashObs, () => this._flushAndLog('hashChange'))); + reader.store.add(runOnChange(repo.headBranchNameObs, () => this._flushAndLog('branchChange'))); + })); + + this._register(new IntervalTimer()).cancelAndSet(() => this._expireAndLog(), 10 * 60 * 60 * 1000); + } + + get resource(): URI { + return this._resource.get(); + } + + setResource(resource: URI): void { + this._resource.set(resource, undefined); + } + + applyEdit(beforeText: string, afterText: string, source: TextModelEditSource, languageId: string): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + await this._document.applyTransition(beforeText, afterText, source, this._computeDiff); + if (!this._isDisposed) { + this._languageId = languageId; + } + }); + } + + flush(trigger: EditTelemetryTrigger): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + const currentText = await this._readCurrentText(this.resource); + if (currentText === undefined || this._isDisposed) { + return; + } + + await this._document.reconcile(currentText, this._computeDiff); + const tracker = this._tracker.value; + if (!tracker) { + return; + } + tracker.applyPendingExternalEdits(); + this._sendTelemetry(trigger, tracker); + this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); + }); + } + + private _sendTelemetry(trigger: EditTelemetryTrigger, tracker: DocumentEditSourceTracker): void { + const retainedByKey = new Map(); + let totalModifiedCount = 0; + for (const range of tracker.getTrackedRanges()) { + if (range.sourceRepresentative.props.$trackingScope !== AGENT_HOST_TRACKING_SCOPE) { + continue; + } + totalModifiedCount += range.range.length; + retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.range.length); + } + + const entries = tracker.getAllKeys() + .map(key => ({ key, representative: tracker.getRepresentative(key), modifiedCount: retainedByKey.get(key) ?? 0 })) + .filter(entry => entry.representative?.props.$trackingScope === AGENT_HOST_TRACKING_SCOPE) + .sort((a, b) => b.modifiedCount - a.modifiedCount) + .slice(0, 30); + if (entries.length === 0) { + return; + } + + const statsUuid = this._generateUuid(); + for (const entry of entries) { + const representative = entry.representative!; + sendEditSourcesDetailsTelemetryData( + this._sendDetails, + representative, + entry.key, + entry.modifiedCount, + tracker.getTotalInsertedCharactersCount(entry.key), + totalModifiedCount, + this._languageId, + statsUuid, + trigger, + ); + } + } + + private _enqueue(operation: () => Promise): Promise { + const result = this._operationQueue.then(operation, operation); + this._operationQueue = result.then(() => undefined, () => undefined); + return result; + } + + private _flushAndLog(trigger: EditTelemetryTrigger): void { + this.flush(trigger).catch(error => this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`)); + } + + private _expireAndLog(): void { + this.flush('10hours').then(() => this._onDidExpire(), error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`); + }); + } + + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } +} + +function sendEditSourcesDetailsTelemetryData( + sendDetails: SendDetails, + representative: TextModelEditSource, + sourceKey: string, + modifiedCount: number, + deltaModifiedCount: number, + totalModifiedCount: number, + languageId: string, + statsUuid: string, + trigger: EditTelemetryTrigger, +): void { + const harness = representative.props.$harness; + sendDetails({ + mode: 'longterm', + sourceKey, + sourceKeyCleaned: representative.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), + extensionId: representative.props.$extensionId, + extensionVersion: representative.props.$extensionVersion, + modelId: representative.props.$modelId, + trigger, + languageId, + statsUuid, + conversationId: representative.props.$$sessionId, + requestId: representative.props.$$requestId, + origin: representative.props.$origin, + harness, + trackingScope: representative.props.$trackingScope, + modifiedCount, + deltaModifiedCount, + totalModifiedCount, + }, harness === 'copilot-sdk'); +} + +/** + * Converts Agent Host file-edit actions into workbench edit-source telemetry. + */ +export class AgentHostEditSourceTracking extends Disposable { + private readonly _connectionListeners = this._register(new MutableDisposable()); + private readonly _trackedFiles = new Map(); + private readonly _diffService: DiffService; + private readonly _scmAdapter: ScmAdapter; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + private readonly _detailsEnabled: IObservable, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, + @IFileService private readonly _fileService: IFileService, + @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @ILanguageService private readonly _languageService: ILanguageService, + @ISCMService scmService: ISCMService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IRandomService private readonly _randomService: IRandomService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._diffService = new DiffService(editorWorkerService); + this._scmAdapter = new ScmAdapter(scmService); + this._syncConnectionListeners(); + this._register(this._connectionsService.onDidChangeConnections(() => this._syncConnectionListeners())); + this._register(autorun(reader => { + if (!this._detailsEnabled.read(reader)) { + this._clearTrackedFiles(); + } + })); + } + + private _syncConnectionListeners(): void { + const store = new DisposableStore(); + for (const connectionInfo of this._connectionsService.connections) { + const connection = connectionInfo.connection; + if (!connection) { + continue; + } + store.add(connection.onDidAction(envelope => { + const action = envelope.action; + if (!this._detailsEnabled.get() || action.type !== ActionType.ChatToolCallComplete || !isAhpChatChannel(envelope.channel.toString())) { + return; + } + this._enqueue(async () => { + if (!this._detailsEnabled.get()) { + return; + } + const session = URI.parse(parseRequiredSessionUriFromChatUri(envelope.channel)); + const provider = AgentSession.provider(session); + if (!provider) { + return; + } + for (const content of action.result.content ?? []) { + if (content.type === ToolResultContentType.FileEdit) { + await this._processFileEdit(connectionInfo.authority, session, provider, action.turnId, content); + } + } + }); + })); + } + this._connectionListeners.value = store; + } + + private async _processFileEdit( + connectionAuthority: string, + session: URI, + provider: string, + turnId: string, + fileEdit: ToolResultFileEditContent, + ): Promise { + const normalized = normalizeFileEdit(fileEdit); + if (!normalized) { + return; + } + + const resource = toAgentHostUri(normalized.resource, connectionAuthority); + if (extname(resource.path).toLowerCase() === '.ipynb') { + return; + } + + const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; + const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; + if (this._isDisposed || !this._detailsEnabled.get() || beforeText === undefined || afterText === undefined || Math.max(beforeText.length, afterText.length) > MAX_TRACKED_FILE_SIZE) { + return; + } + + const harness = provider === 'copilot' ? 'copilot-sdk' : provider; + const agentSessionId = AgentSession.id(session); + const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(resource, firstLine(afterText || beforeText)) ?? 'plaintext'; + const source = EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: agentSessionId, + requestId: turnId, + languageId, + mode: undefined, + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness, + origin: 'agentHost', + trackingScope: AGENT_HOST_TRACKING_SCOPE, + }); + + const resourceKey = this._uriIdentityService.extUri.getComparisonKey(resource); + const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; + const beforeResourceKey = beforeResource ? this._uriIdentityService.extUri.getComparisonKey(beforeResource) : undefined; + let trackedFile = this._trackedFiles.get(resourceKey); + if (!trackedFile && beforeResourceKey && beforeResourceKey !== resourceKey) { + trackedFile = this._trackedFiles.get(beforeResourceKey); + if (trackedFile) { + this._trackedFiles.delete(beforeResourceKey); + this._trackedFiles.set(resourceKey, trackedFile); + trackedFile.setResource(resource); + } + } + if (!trackedFile) { + const createdTrackedFile = new AgentHostTrackedFile( + resource, + beforeText, + currentResource => this._readCurrentText(currentResource), + (original, modified) => this._diffService.computeDiff(original, modified), + (repoResource, reader) => this._scmAdapter.getRepo(repoResource, reader), + () => this._randomService.generateUuid(), + (data, forwardToGitHub) => sendEditSourcesDetailsTelemetry(this._telemetryService, data, forwardToGitHub), + this._logService, + () => this._removeTrackedFile(createdTrackedFile), + ); + trackedFile = createdTrackedFile; + this._trackedFiles.set(resourceKey, trackedFile); + } + + await trackedFile.applyEdit(beforeText, afterText, source, languageId); + } + + private async _readSnapshot(resource: URI, connectionAuthority: string): Promise { + return this._readText(toAgentHostUri(resource, connectionAuthority), false); + } + + private async _readCurrentText(resource: URI): Promise { + return this._readText(resource, true); + } + + private async _readText(resource: URI, missingAsEmpty: boolean): Promise { + try { + const value = (await this._fileService.readFile(resource)).value.toString(); + if (value.includes('\0')) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping binary file ${resource.toString()}`); + return undefined; + } + return value; + } catch (error) { + if (missingAsEmpty && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return ''; + } + throw error; + } + } + + private _enqueue(operation: () => Promise): void { + const run = async () => { + if (!this._isDisposed) { + await operation(); + } + }; + const result = this._operationQueue.then(run, run); + this._operationQueue = result.catch(error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to process Agent Host edit: ${error}`); + }); + } + + private _removeTrackedFile(trackedFile: AgentHostTrackedFile): void { + for (const [key, value] of this._trackedFiles) { + if (value === trackedFile) { + this._trackedFiles.delete(key); + trackedFile.dispose(); + return; + } + } + } + + private _clearTrackedFiles(): void { + for (const trackedFile of this._trackedFiles.values()) { + trackedFile.dispose(); + } + this._trackedFiles.clear(); + } + + override dispose(): void { + this._isDisposed = true; + this._clearTrackedFiles(); + super.dispose(); + } +} + +function firstLine(text: string): string { + const lineBreak = text.search(/\r\n|\r|\n/); + return lineBreak === -1 ? text : text.substring(0, lineBreak); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts new file mode 100644 index 00000000000000..d41e047d36284c --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { forwardToChannelIf } from '../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; + +export type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; +export type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; + +export interface IEditSourcesDetailsTelemetryData { + mode: EditTelemetryMode; + sourceKey: string; + sourceKeyCleaned: string; + extensionId: string | undefined; + extensionVersion: string | undefined; + modelId: string | undefined; + trigger: EditTelemetryTrigger; + languageId: string; + statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; + origin: string | undefined; + harness: string | undefined; + trackingScope: string | undefined; + modifiedCount: number; + deltaModifiedCount: number; + totalModifiedCount: number; +} + +type EditSourcesDetailsTelemetryClassification = { + owner: 'hediet'; + comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; + mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; + sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; + sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; + modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; + origin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The system that observed and attributed the edit.' }; + harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent harness that produced the edit.' }; + trackingScope: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The set of edit sources represented by the row.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; + deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; + totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; +}; + +export function sendEditSourcesDetailsTelemetry(telemetryService: ITelemetryService, data: IEditSourcesDetailsTelemetryData, forwardToGitHub?: boolean): void { + telemetryService.publicLog2('editTelemetry.editSources.details', { + ...data, + ...(forwardToGitHub === undefined ? {} : forwardToChannelIf(forwardToGitHub)), + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index 2a7e05d78b8a84..82d53b7fe6dc9e 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -27,6 +27,7 @@ import { DataChannelForwardingTelemetryService } from '../../../../../platform/d import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from '../settings.js'; import { VSCodeWorkspace } from '../helpers/vscodeObservableWorkspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { AgentHostEditSourceTracking } from './agentHostEditSourceTracking.js'; export class EditTrackingFeature extends Disposable { @@ -69,6 +70,7 @@ export class EditTrackingFeature extends Disposable { [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments)); + this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails)); this._register(autorun((reader) => { if (!this._editSourceTrackingShowDecorations.read(reader)) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index ff534ab884a5ef..04ea1dce5f5684 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -17,9 +17,7 @@ import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; - -type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; -type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; +import { EditTelemetryMode, EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; export type EditTelemetryCategory = 'nes' | 'inlineCompletionsCopilot' | 'inlineCompletionsNES' | 'inlineCompletionsOther' | 'otherAI' | 'user' | 'ide' | 'external' | 'unknown'; @@ -213,60 +211,21 @@ class TrackedDocumentInfo extends Disposable { const repr = t.getRepresentative(key)!; const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); - this._telemetryService.publicLog2<{ - mode: EditTelemetryMode; - sourceKey: string; - - sourceKeyCleaned: string; - extensionId: string | undefined; - extensionVersion: string | undefined; - modelId: string | undefined; - - trigger: EditTelemetryTrigger; - languageId: string; - statsUuid: string; - conversationId: string | undefined; - requestId: string | undefined; - modifiedCount: number; - deltaModifiedCount: number; - totalModifiedCount: number; - }, { - owner: 'hediet'; - comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; - - mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; - sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; - - sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; - extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; - extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; - modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; - - languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; - statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; - conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; - requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; - - trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; - - modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; - totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - - }>('editTelemetry.editSources.details', { + sendEditSourcesDetailsTelemetry(this._telemetryService, { mode, sourceKey: key, - sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), extensionId: repr.props.$extensionId, extensionVersion: repr.props.$extensionVersion, modelId: repr.props.$modelId, - trigger, languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, conversationId: repr.props.$$sessionId, requestId: repr.props.$$requestId, + origin: repr.props.$origin, + harness: repr.props.$harness, + trackingScope: repr.props.$trackingScope, modifiedCount: value, deltaModifiedCount: deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts index 59553b0d418fda..afffaa88706151 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts @@ -80,6 +80,15 @@ export class DocumentEditSourceTracker extends Disposable { return this._representativePerKey.get(key); } + public applyPendingExternalEdits(): void { + if (this._pendingExternalEdits.isEmpty()) { + return; + } + this._applyEdit(this._pendingExternalEdits); + this._pendingExternalEdits = AnnotatedStringEdit.empty; + this._update.trigger(undefined); + } + public getTrackedRanges(reader?: IReader): TrackedEdit[] { this._update.read(reader); const ranges = this._edits.getNewRanges(); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts new file mode 100644 index 00000000000000..d10e19e3cc5e1e --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AgentHostTrackedFile } from '../../browser/telemetry/agentHostEditSourceTracking.js'; +import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; + +suite('Agent Host Edit Source Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks AI edits separately from reconciliation edits', async () => { + const disposables = new DisposableStore(); + let currentText = ''; + let uuid = 0; + const sentTelemetry: { data: IEditSourcesDetailsTelemetryData; forwardToGitHub: boolean }[] = []; + const trackedFile = disposables.add(new AgentHostTrackedFile( + URI.file('C:\\repo\\file.ts'), + '', + async () => currentText, + async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + () => undefined, + () => `stats-${++uuid}`, + (data, forwardToGitHub) => sentTelemetry.push({ data, forwardToGitHub }), + new NullLogService(), + () => { }, + )); + + await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilot-sdk', 'session-1', 'turn-1'), 'typescript'); + await trackedFile.applyEdit('alpha\n', 'alpha\nbeta\n', agentHostEditSource('claude', 'session-1', 'turn-2'), 'typescript'); + currentText = 'alpha\nX\n'; + await trackedFile.flush('hashChange'); + await trackedFile.flush('hashChange'); + + assert.deepStrictEqual(sentTelemetry, [ + { + data: { + mode: 'longterm', + sourceKey: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + conversationId: 'session-1', + requestId: 'turn-1', + origin: 'agentHost', + harness: 'copilot-sdk', + trackingScope: 'agentHostAIOnly', + modifiedCount: 6, + deltaModifiedCount: 6, + totalModifiedCount: 7, + }, + forwardToGitHub: true, + }, + { + data: { + mode: 'longterm', + sourceKey: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + conversationId: 'session-1', + requestId: 'turn-2', + origin: 'agentHost', + harness: 'claude', + trackingScope: 'agentHostAIOnly', + modifiedCount: 1, + deltaModifiedCount: 5, + totalModifiedCount: 7, + }, + forwardToGitHub: false, + }, + ]); + + disposables.dispose(); + }); +}); + +function agentHostEditSource(harness: string, sessionId: string, turnId: string) { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId, + requestId: turnId, + languageId: 'typescript', + mode: undefined, + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness, + origin: 'agentHost', + trackingScope: 'agentHostAIOnly', + }); +} From ddf8ca28db3f842ce58cece94e8aa1c9f41f427d Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:15:08 -0700 Subject: [PATCH 12/21] Fix Agent Host edit telemetry forwarding Use the canonical copilotcli provider identifier for harness metadata and GitHub edit-telemetry forwarding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/telemetry/agentHostEditSourceTracking.ts | 4 ++-- .../test/browser/agentHostEditSourceTracking.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 89b46230263566..72c2acc652ac62 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -249,7 +249,7 @@ function sendEditSourcesDetailsTelemetryData( modifiedCount, deltaModifiedCount, totalModifiedCount, - }, harness === 'copilot-sdk'); + }, harness === 'copilotcli'); } /** @@ -342,7 +342,7 @@ export class AgentHostEditSourceTracking extends Disposable { return; } - const harness = provider === 'copilot' ? 'copilot-sdk' : provider; + const harness = provider; const agentSessionId = AgentSession.id(session); const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(resource, firstLine(afterText || beforeText)) ?? 'plaintext'; const source = EditSources.chatApplyEdits({ diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index d10e19e3cc5e1e..cf2ee29619c873 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -33,7 +33,7 @@ suite('Agent Host Edit Source Tracking', () => { () => { }, )); - await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilot-sdk', 'session-1', 'turn-1'), 'typescript'); + await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilotcli', 'session-1', 'turn-1'), 'typescript'); await trackedFile.applyEdit('alpha\n', 'alpha\nbeta\n', agentHostEditSource('claude', 'session-1', 'turn-2'), 'typescript'); currentText = 'alpha\nX\n'; await trackedFile.flush('hashChange'); @@ -43,8 +43,8 @@ suite('Agent Host Edit Source Tracking', () => { { data: { mode: 'longterm', - sourceKey: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', - sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilot-sdk-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', extensionId: undefined, extensionVersion: undefined, modelId: undefined, @@ -54,7 +54,7 @@ suite('Agent Host Edit Source Tracking', () => { conversationId: 'session-1', requestId: 'turn-1', origin: 'agentHost', - harness: 'copilot-sdk', + harness: 'copilotcli', trackingScope: 'agentHostAIOnly', modifiedCount: 6, deltaModifiedCount: 6, From d3e7eaf124b6d45787a021e88b91ae4f033b4421 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:47:26 -0700 Subject: [PATCH 13/21] Skip Agent Host attribution for dirty files Avoid recording disk-based Agent Host edit attribution when an open text model has unsaved changes, pending a product decision on dirty-document reconciliation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../telemetry/agentHostEditSourceTracking.ts | 16 ++++++++++++++++ .../agentHostEditSourceTracking.test.ts | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 72c2acc652ac62..307dab52a95565 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -12,6 +12,7 @@ import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/co import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -24,6 +25,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { ISCMService } from '../../../scm/common/scm.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; import { IRandomService } from '../randomService.js'; import { DocumentEditSourceTracker } from './editTracker.js'; @@ -268,7 +270,9 @@ export class AgentHostEditSourceTracking extends Disposable { @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IFileService private readonly _fileService: IFileService, @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @IModelService private readonly _modelService: IModelService, @ILanguageService private readonly _languageService: ILanguageService, + @ITextFileService private readonly _textFileService: ITextFileService, @ISCMService scmService: ISCMService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IRandomService private readonly _randomService: IRandomService, @@ -335,6 +339,14 @@ export class AgentHostEditSourceTracking extends Disposable { if (extname(resource.path).toLowerCase() === '.ipynb') { return; } + const editedResources = [normalized.beforeUri, normalized.afterUri] + .filter(resource => resource !== undefined) + .map(resource => toAgentHostUri(resource, connectionAuthority)); + const dirtyResource = editedResources.find(resource => isDirtyOpenTextModel(resource, this._modelService, this._textFileService)); + if (dirtyResource) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); + return; + } const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; @@ -453,3 +465,7 @@ function firstLine(text: string): string { const lineBreak = text.search(/\r\n|\r|\n/); return lineBreak === -1 ? text : text.substring(0, lineBreak); } + +export function isDirtyOpenTextModel(resource: URI, modelService: Pick, textFileService: Pick): boolean { + return modelService.getModel(resource) !== null && textFileService.isDirty(resource); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index cf2ee29619c873..b770256969d4e3 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -8,9 +8,10 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { AgentHostTrackedFile } from '../../browser/telemetry/agentHostEditSourceTracking.js'; +import { AgentHostTrackedFile, isDirtyOpenTextModel } from '../../browser/telemetry/agentHostEditSourceTracking.js'; import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; suite('Agent Host Edit Source Tracking', () => { @@ -88,6 +89,21 @@ suite('Agent Host Edit Source Tracking', () => { disposables.dispose(); }); + + test('only skips attribution for open dirty text models', () => { + const resource = URI.file('C:\\repo\\file.ts'); + const model = Object.create(null) as ITextModel; + + assert.deepStrictEqual({ + closedDirty: isDirtyOpenTextModel(resource, { getModel: () => null }, { isDirty: () => true }), + openClean: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => false }), + openDirty: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => true }), + }, { + closedDirty: false, + openClean: false, + openDirty: true, + }); + }); }); function agentHostEditSource(harness: string, sessionId: string, turnId: string) { From b3df4d7e7a9eaafd33536d0a6e67b07de66f6d4d Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:00:15 -0700 Subject: [PATCH 14/21] Add unified edit document reconciler Introduce a service-free state machine that reconciles model edits, Agent Host transitions, and disk snapshots with deterministic duplicate, conflict, and dirty-model outcomes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../helpers/unifiedDocumentReconciler.ts | 377 +++++++++++++++++ .../browser/unifiedDocumentReconciler.test.ts | 381 ++++++++++++++++++ 2 files changed, 758 insertions(+) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts new file mode 100644 index 00000000000000..5b27772089ff09 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts @@ -0,0 +1,377 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type UnifiedDocumentReconcileOutcome = 'applied' | 'duplicate' | 'conflict' | 'skippedDirty'; + +export type UnifiedDocumentTransitionKind = 'model' | 'reloadFromDisk' | 'agentHost' | 'diskSnapshot'; + +export type UnifiedDocumentAgentTransitionKind = 'create' | 'edit' | 'delete' | 'rename'; + +export interface IUnifiedDocumentModelState { + readonly content: string; + readonly dirty: boolean; +} + +export interface IUnifiedDocumentModelEdit { + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly kind: 'model' | 'reloadFromDisk'; + readonly dirty: boolean; +} + +export interface IUnifiedDocumentAgentTransition { + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly correlation: string; + readonly kind: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentTransition { + readonly id: number; + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly kind: UnifiedDocumentTransitionKind; + readonly correlation?: string; + readonly agentKind?: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentTransitionChange { + readonly kind: 'append' | 'replace'; + readonly transition: IUnifiedDocumentTransition; +} + +export interface IUnifiedDocumentSnapshot { + readonly initialContent: string; + readonly content: string; + readonly diskContent: string; + readonly model: IUnifiedDocumentModelState | undefined; + readonly pendingReload: IUnifiedDocumentTransition | undefined; + readonly transitions: readonly IUnifiedDocumentTransition[]; +} + +export interface IUnifiedDocumentReconcileResult { + readonly outcome: UnifiedDocumentReconcileOutcome; + readonly changes: readonly IUnifiedDocumentTransitionChange[]; + readonly snapshot: IUnifiedDocumentSnapshot; +} + +/** + * Reconciles model, Agent Host, and disk observations into one canonical edit sequence. + */ +export class UnifiedDocumentReconciler { + private readonly _initialContent: string; + private _content: string; + private _diskContent: string; + private _model: IUnifiedDocumentModelState | undefined; + private _pendingReload: IUnifiedDocumentTransition | undefined; + private readonly _transitions: IUnifiedDocumentTransition[] = []; + private readonly _agentCorrelations = new Map(); + private _nextTransitionId = 1; + + constructor( + initialContent: string, + private readonly _externalSource: TSource, + ) { + this._initialContent = initialContent; + this._content = initialContent; + this._diskContent = initialContent; + } + + modelConnected(state: IUnifiedDocumentModelState): IUnifiedDocumentReconcileResult { + if (this._model) { + return this._result('conflict'); + } + + this._model = { ...state }; + if (state.content === this._content) { + return this._result('applied'); + } + if (state.dirty) { + return this._result('skippedDirty'); + } + if (state.content !== this._diskContent) { + return this._result('conflict'); + } + + const changes = this._commitPendingReload(); + changes.push(this._appendTransition(this._content, state.content, this._externalSource, 'diskSnapshot')); + this._content = state.content; + return this._result('applied', changes); + } + + modelDisconnected(): IUnifiedDocumentReconcileResult { + if (!this._model) { + return this._result('duplicate'); + } + this._model = undefined; + return this._result('applied'); + } + + modelEdit(edit: IUnifiedDocumentModelEdit): IUnifiedDocumentReconcileResult { + if (!this._model || this._model.content !== edit.before) { + return this._result('conflict'); + } + + if (edit.kind === 'reloadFromDisk') { + const matchingAgentTransition = this._findTransition(edit.before, edit.after, 'agentHost'); + if (matchingAgentTransition && this._content === edit.after && this._diskContent === edit.after) { + this._model = { content: edit.after, dirty: edit.dirty }; + this._diskContent = edit.after; + return this._result('duplicate'); + } + const matchingExternalTransition = this._findExternalTransition(edit.before, edit.after); + if (matchingExternalTransition && this._content === edit.after && this._diskContent === edit.after) { + this._model = { content: edit.after, dirty: edit.dirty }; + return this._result('duplicate'); + } + if (this._pendingReload && isSameContentTransition(this._pendingReload, edit)) { + this._model = { content: edit.after, dirty: edit.dirty }; + this._diskContent = edit.after; + this._content = edit.after; + return this._result('duplicate'); + } + } + + const changes = this._commitPendingReload(); + if (this._content !== edit.before) { + this._model = { content: edit.after, dirty: edit.dirty }; + if (edit.kind === 'reloadFromDisk') { + this._diskContent = edit.after; + } + return this._result('conflict', changes); + } + + this._model = { content: edit.after, dirty: edit.dirty }; + this._content = edit.after; + if (edit.kind === 'reloadFromDisk') { + this._diskContent = edit.after; + this._pendingReload = this._createTransition(edit.before, edit.after, edit.source, edit.kind); + return this._result('applied', changes); + } + + if (edit.before === edit.after) { + return this._result('duplicate', changes); + } + changes.push(this._appendTransition(edit.before, edit.after, edit.source, edit.kind)); + return this._result('applied', changes); + } + + agentTransition(transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentReconcileResult { + const correlated = this._agentCorrelations.get(transition.correlation); + if (correlated) { + return this._result( + correlated.before === transition.before && correlated.after === transition.after ? 'duplicate' : 'conflict' + ); + } + + const existingAgentTransition = this._findTransition(transition.before, transition.after, 'agentHost'); + if (existingAgentTransition && this._content === transition.after && this._diskContent === transition.after) { + this._recordAgentCorrelation(transition, 'duplicate'); + return this._result('duplicate'); + } + + const matchingExternalTransition = this._findExternalTransition(transition.before, transition.after); + if (matchingExternalTransition && this._diskContent === transition.after) { + const replacement = this._replaceWithAgentTransition(matchingExternalTransition, transition); + this._recordAgentCorrelation(transition, 'applied'); + return this._result('applied', [{ kind: 'replace', transition: replacement }]); + } + + if (this._pendingReload && isSameContentTransition(this._pendingReload, transition)) { + const pendingReload = this._pendingReload; + const appliedTransition: IUnifiedDocumentTransition = { + ...pendingReload, + source: transition.source, + kind: 'agentHost', + correlation: transition.correlation, + agentKind: transition.kind, + }; + this._pendingReload = undefined; + this._transitions.push(appliedTransition); + this._recordAgentCorrelation(transition, 'applied'); + return this._result('applied', [{ kind: 'append', transition: { ...appliedTransition } }]); + } + + const changes = this._commitPendingReload(); + if (this._model?.dirty) { + if (this._diskContent === transition.before) { + this._diskContent = transition.after; + } + this._recordAgentCorrelation(transition, 'skippedDirty'); + return this._result('skippedDirty', changes); + } + if (this._diskContent !== transition.before || this._content !== transition.before) { + return this._result('conflict', changes); + } + + this._diskContent = transition.after; + this._content = transition.after; + this._recordAgentCorrelation(transition, 'applied'); + if (transition.before === transition.after) { + return this._result('applied', changes); + } + changes.push(this._appendAgentTransition(transition)); + return this._result('applied', changes); + } + + diskSnapshot(content: string): IUnifiedDocumentReconcileResult { + if (this._pendingReload && content === this._pendingReload.after) { + const changes = this._commitPendingReload(); + this._diskContent = content; + return this._result('applied', changes); + } + + const changes = this._commitPendingReload(); + if (content === this._diskContent && content === this._content) { + return this._result('duplicate', changes); + } + + const previousDiskContent = this._diskContent; + this._diskContent = content; + const isModelSave = this._model?.dirty === true && content === this._model.content; + if (isModelSave && this._model) { + this._model = { content: this._model.content, dirty: false }; + } + if (this._model?.dirty && content !== this._model.content) { + return this._result('conflict', changes); + } + if (content === this._content) { + return this._result('duplicate', changes); + } + if (!isModelSave && this._content !== previousDiskContent) { + return this._result('conflict', changes); + } + + changes.push(this._appendTransition(this._content, content, this._externalSource, 'diskSnapshot')); + this._content = content; + return this._result('applied', changes); + } + + getSnapshot(): IUnifiedDocumentSnapshot { + return { + initialContent: this._initialContent, + content: this._content, + diskContent: this._diskContent, + model: this._model ? { ...this._model } : undefined, + pendingReload: this._pendingReload ? { ...this._pendingReload } : undefined, + transitions: this._transitions.map(transition => ({ ...transition })), + }; + } + + private _commitPendingReload(): IUnifiedDocumentTransitionChange[] { + if (!this._pendingReload) { + return []; + } + const transition = this._pendingReload; + this._pendingReload = undefined; + this._transitions.push(transition); + return [{ kind: 'append', transition: { ...transition } }]; + } + + private _appendAgentTransition(transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentTransitionChange { + return this._appendTransition(transition.before, transition.after, transition.source, 'agentHost', transition.correlation, transition.kind); + } + + private _appendTransition( + before: string, + after: string, + source: TSource, + kind: UnifiedDocumentTransitionKind, + correlation?: string, + agentKind?: UnifiedDocumentAgentTransitionKind, + ): IUnifiedDocumentTransitionChange { + const transition = this._createTransition(before, after, source, kind, correlation, agentKind); + this._transitions.push(transition); + return { kind: 'append', transition: { ...transition } }; + } + + private _createTransition( + before: string, + after: string, + source: TSource, + kind: UnifiedDocumentTransitionKind, + correlation?: string, + agentKind?: UnifiedDocumentAgentTransitionKind, + ): IUnifiedDocumentTransition { + return { + id: this._nextTransitionId++, + before, + after, + source, + kind, + correlation, + agentKind, + }; + } + + private _findTransition(before: string, after: string, kind: UnifiedDocumentTransitionKind): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if (transition.kind === kind && transition.before === before && transition.after === after) { + return transition; + } + } + return undefined; + } + + private _findExternalTransition(before: string, after: string): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if ( + (transition.kind === 'reloadFromDisk' || transition.kind === 'diskSnapshot') && + transition.before === before && + transition.after === after + ) { + return transition; + } + } + return undefined; + } + + private _replaceWithAgentTransition( + existing: IUnifiedDocumentTransition, + agentTransition: IUnifiedDocumentAgentTransition, + ): IUnifiedDocumentTransition { + const replacement: IUnifiedDocumentTransition = { + ...existing, + source: agentTransition.source, + kind: 'agentHost', + correlation: agentTransition.correlation, + agentKind: agentTransition.kind, + }; + const index = this._transitions.findIndex(transition => transition.id === existing.id); + this._transitions[index] = replacement; + return replacement; + } + + private _recordAgentCorrelation(transition: IUnifiedDocumentAgentTransition, outcome: UnifiedDocumentReconcileOutcome): void { + this._agentCorrelations.set(transition.correlation, { + before: transition.before, + after: transition.after, + outcome, + }); + } + + private _result( + outcome: UnifiedDocumentReconcileOutcome, + changes: readonly IUnifiedDocumentTransitionChange[] = [], + ): IUnifiedDocumentReconcileResult { + return { + outcome, + changes, + snapshot: this.getSnapshot(), + }; + } +} + +function isSameContentTransition( + first: { readonly before: string; readonly after: string }, + second: { readonly before: string; readonly after: string }, +): boolean { + return first.before === second.before && first.after === second.after; +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts new file mode 100644 index 00000000000000..636bf560aba654 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts @@ -0,0 +1,381 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; + +suite('UnifiedDocumentReconciler', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('deduplicates Agent Host followed by model reload', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + + test('reattributes model reload followed by Agent Host', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + const reloadResult = reconciler.modelEdit(reloadEdit('before', 'after')); + assert.deepStrictEqual( + { outcome: reloadResult.outcome, changes: reloadResult.changes, pending: reloadResult.snapshot.pendingReload?.kind }, + { outcome: 'applied', changes: [], pending: 'reloadFromDisk' }, + ); + const agentResult = reconciler.agentTransition(agentEdit('before', 'after')); + assert.deepStrictEqual( + { outcome: agentResult.outcome, changes: agentResult.changes.map(change => change.kind) }, + { outcome: 'applied', changes: ['append'] }, + ); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + + test('produces identical final state for either Agent Host and reload order', () => { + const agentFirst = createReconciler('before'); + agentFirst.modelConnected({ content: 'before', dirty: false }); + agentFirst.agentTransition(agentEdit('before', 'after')); + agentFirst.modelEdit(reloadEdit('before', 'after')); + + const reloadFirst = createReconciler('before'); + reloadFirst.modelConnected({ content: 'before', dirty: false }); + reloadFirst.modelEdit(reloadEdit('before', 'after')); + reloadFirst.agentTransition(agentEdit('before', 'after')); + + assert.deepStrictEqual(reloadFirst.getSnapshot(), agentFirst.getSnapshot()); + }); + + test('commits unmatched reload as external on disk snapshot', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit(reloadEdit('before', 'after')); + + const result = reconciler.diskSnapshot('after'); + assert.deepStrictEqual( + { outcome: result.outcome, changes: result.changes }, + { + outcome: 'applied', + changes: [{ + kind: 'append', + transition: { + id: 1, + before: 'before', + after: 'after', + source: 'reload', + kind: 'reloadFromDisk', + correlation: undefined, + agentKind: undefined, + }, + }], + }, + ); + }); + + test('skips new Agent Host attribution for a dirty model', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'user edit', dirty: true }); + + const result = reconciler.agentTransition(agentEdit('base', 'agent edit')); + assert.deepStrictEqual( + { outcome: result.outcome, snapshot: result.snapshot }, + { + outcome: 'skippedDirty', + snapshot: { + initialContent: 'base', + content: 'base', + diskContent: 'agent edit', + model: { content: 'user edit', dirty: true }, + pendingReload: undefined, + transitions: [], + }, + }, + ); + assert.strictEqual(reconciler.agentTransition(agentEdit('base', 'agent edit')).outcome, 'duplicate'); + }); + + test('claims an already observed reload even if the model became dirty later', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit(reloadEdit('before', 'after')); + reconciler.modelEdit({ + before: 'after', + after: 'after user', + source: 'user', + kind: 'model', + dirty: true, + }); + + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions.map(transition => transition.kind), ['agentHost', 'model']); + }); + + test('tracks create and delete Agent Host transitions', () => { + const reconciler = createReconciler(''); + + assert.strictEqual(reconciler.agentTransition(agentEdit('', 'created', 'create-1', 'create')).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(agentEdit('created', '', 'delete-1', 'delete')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + agentKind: transition.agentKind, + })), + [ + { before: '', after: 'created', agentKind: 'create' }, + { before: 'created', after: '', agentKind: 'delete' }, + ], + ); + }); + + test('does not deduplicate a later real transition after content cycles', () => { + const reconciler = createReconciler('a'); + reconciler.agentTransition(agentEdit('a', 'b', 'agent-1')); + reconciler.agentTransition(agentEdit('b', 'a', 'agent-2')); + + assert.strictEqual(reconciler.agentTransition(agentEdit('a', 'b', 'agent-3')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => transition.correlation), + ['agent-1', 'agent-2', 'agent-3'], + ); + }); + + test('does not claim an old external transition after disk content cycles', () => { + const reconciler = createReconciler('a'); + reconciler.diskSnapshot('b'); + reconciler.diskSnapshot('a'); + + assert.strictEqual(reconciler.agentTransition(agentEdit('a', 'b')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + kind: transition.kind, + })), + [ + { before: 'a', after: 'b', kind: 'diskSnapshot' }, + { before: 'b', after: 'a', kind: 'diskSnapshot' }, + { before: 'a', after: 'b', kind: 'agentHost' }, + ], + ); + }); + + test('applies disk snapshots as external edits without a model', () => { + const reconciler = createReconciler('before'); + + assert.strictEqual(reconciler.diskSnapshot('after').outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + source: transition.source, + kind: transition.kind, + })), + [{ before: 'before', after: 'after', source: 'external', kind: 'diskSnapshot' }], + ); + }); + + test('deduplicates a disk snapshot followed by model reload', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + assert.strictEqual(reconciler.diskSnapshot('after').outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => transition.kind), + ['diskSnapshot'], + ); + }); + + test('treats a model save as synchronization rather than another edit', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'user edit', + source: 'user', + kind: 'model', + dirty: true, + }); + + assert.strictEqual(reconciler.diskSnapshot('user edit').outcome, 'duplicate'); + assert.deepStrictEqual( + { + model: reconciler.getSnapshot().model, + sources: reconciler.getSnapshot().transitions.map(transition => transition.source), + }, + { model: { content: 'user edit', dirty: false }, sources: ['user'] }, + ); + assert.strictEqual(reconciler.agentTransition(agentEdit('user edit', 'agent edit')).outcome, 'applied'); + }); + + test('reports a disk conflict while the model is dirty', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'user edit', + source: 'user', + kind: 'model', + dirty: true, + }); + + const result = reconciler.diskSnapshot('external edit'); + assert.deepStrictEqual( + { outcome: result.outcome, content: result.snapshot.content, diskContent: result.snapshot.diskContent }, + { outcome: 'conflict', content: 'user edit', diskContent: 'external edit' }, + ); + }); + + test('continues observing a skipped dirty model and resynchronizes on save', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'dirty one', dirty: true }); + + const modelEditResult = reconciler.modelEdit({ + before: 'dirty one', + after: 'dirty two', + source: 'user', + kind: 'model', + dirty: true, + }); + assert.deepStrictEqual( + { outcome: modelEditResult.outcome, model: modelEditResult.snapshot.model }, + { outcome: 'conflict', model: { content: 'dirty two', dirty: true } }, + ); + + const saveResult = reconciler.diskSnapshot('dirty two'); + assert.deepStrictEqual( + { + outcome: saveResult.outcome, + model: saveResult.snapshot.model, + content: saveResult.snapshot.content, + transition: saveResult.snapshot.transitions[0], + }, + { + outcome: 'applied', + model: { content: 'dirty two', dirty: false }, + content: 'dirty two', + transition: { + id: 1, + before: 'base', + after: 'dirty two', + source: 'external', + kind: 'diskSnapshot', + correlation: undefined, + agentKind: undefined, + }, + }, + ); + }); + + test('resynchronizes when a dirty save overwrites a skipped Agent Host edit', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'user edit', dirty: true }); + reconciler.agentTransition(agentEdit('base', 'agent edit')); + + const result = reconciler.diskSnapshot('user edit'); + assert.deepStrictEqual( + { + outcome: result.outcome, + content: result.snapshot.content, + diskContent: result.snapshot.diskContent, + model: result.snapshot.model, + transition: result.snapshot.transitions[0], + }, + { + outcome: 'applied', + content: 'user edit', + diskContent: 'user edit', + model: { content: 'user edit', dirty: false }, + transition: { + id: 1, + before: 'base', + after: 'user edit', + source: 'external', + kind: 'diskSnapshot', + correlation: undefined, + agentKind: undefined, + }, + }, + ); + }); + + test('accepts rename as a correlated no-content transition', () => { + const reconciler = createReconciler('content'); + const rename = agentEdit('content', 'content', 'rename-1', 'rename'); + + assert.strictEqual(reconciler.agentTransition(rename).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(rename).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions, []); + }); + + test('connects, disconnects, and reconnects without resetting attribution', () => { + const reconciler = createReconciler('before'); + reconciler.agentTransition(agentEdit('before', 'after')); + + assert.strictEqual(reconciler.modelConnected({ content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(reconciler.modelDisconnected().outcome, 'applied'); + assert.strictEqual(reconciler.modelConnected({ content: 'after', dirty: false }).outcome, 'applied'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions, expectedAgentSnapshot('before', 'after').transitions); + }); + + test('reports conflicting state and correlation reuse', () => { + const reconciler = createReconciler('before'); + + assert.strictEqual(reconciler.agentTransition(agentEdit('other', 'after')).outcome, 'conflict'); + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'different')).outcome, 'conflict'); + }); +}); + +function createReconciler(initialContent: string): UnifiedDocumentReconciler { + return new UnifiedDocumentReconciler(initialContent, 'external'); +} + +function agentEdit( + before: string, + after: string, + correlation = 'agent-1', + kind: 'create' | 'edit' | 'delete' | 'rename' = 'edit', +) { + return { + before, + after, + source: 'agent', + correlation, + kind, + } as const; +} + +function reloadEdit(before: string, after: string) { + return { + before, + after, + source: 'reload', + kind: 'reloadFromDisk', + dirty: false, + } as const; +} + +function expectedAgentSnapshot(initialContent: string, content: string) { + return { + initialContent, + content, + diskContent: content, + model: { content, dirty: false }, + pendingReload: undefined, + transitions: [{ + id: 1, + before: initialContent, + after: content, + source: 'agent', + kind: 'agentHost', + correlation: 'agent-1', + agentKind: 'edit', + }], + }; +} From 96a336abefec40ea386d6c5edbfd2461939992e7 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:12:53 -0700 Subject: [PATCH 15/21] Add unified edit document registry Keep one reconciler per canonical resource across remote authorities, model lifecycle changes, and resource renames. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../helpers/unifiedDocumentRegistry.ts | 156 ++++++++++++++++ .../browser/unifiedDocumentRegistry.test.ts | 174 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts new file mode 100644 index 00000000000000..874a800dd96c1f --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { + IUnifiedDocumentAgentTransition, + IUnifiedDocumentModelEdit, + IUnifiedDocumentModelState, + IUnifiedDocumentReconcileResult, + UnifiedDocumentReconcileOutcome, + UnifiedDocumentReconciler, +} from './unifiedDocumentReconciler.js'; + +export interface IUnifiedDocumentRegistryOptions { + readonly externalSource: TSource; + readonly canonicalize: (resource: URI) => URI; + readonly getComparisonKey: (resource: URI) => string; +} + +export interface IUnifiedDocumentRegistryEntry { + readonly resource: URI; + readonly reconciler: UnifiedDocumentReconciler; +} + +export interface IUnifiedDocumentRegistryResult { + readonly outcome: UnifiedDocumentReconcileOutcome; + readonly resource: URI; + readonly reconcileResult?: IUnifiedDocumentReconcileResult; +} + +class UnifiedDocumentRegistryEntry implements IUnifiedDocumentRegistryEntry { + constructor( + public resource: URI, + readonly reconciler: UnifiedDocumentReconciler, + ) { } +} + +/** + * Owns one unified reconciler per canonical resource identity. + */ +export class UnifiedDocumentRegistry { + private readonly _entries = new Map>(); + + constructor(private readonly _options: IUnifiedDocumentRegistryOptions) { } + + get size(): number { + return this._entries.size; + } + + get(resource: URI): IUnifiedDocumentRegistryEntry | undefined { + return this._entries.get(this._key(resource)); + } + + entries(): readonly IUnifiedDocumentRegistryEntry[] { + return Array.from(this._entries.values()); + } + + modelConnected(resource: URI, initialContent: string, state: IUnifiedDocumentModelState): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, initialContent); + return this._wrap(entry, entry.reconciler.modelConnected(state)); + } + + modelDisconnected(resource: URI): IUnifiedDocumentRegistryResult { + const canonicalResource = this._canonicalize(resource); + const entry = this._entries.get(this._key(canonicalResource)); + if (!entry) { + return { outcome: 'duplicate', resource: canonicalResource }; + } + return this._wrap(entry, entry.reconciler.modelDisconnected()); + } + + modelEdit(resource: URI, edit: IUnifiedDocumentModelEdit): IUnifiedDocumentRegistryResult { + const canonicalResource = this._canonicalize(resource); + const entry = this._entries.get(this._key(canonicalResource)); + if (!entry) { + return { outcome: 'conflict', resource: canonicalResource }; + } + return this._wrap(entry, entry.reconciler.modelEdit(edit)); + } + + agentTransition(resource: URI, transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, transition.before); + return this._wrap(entry, entry.reconciler.agentTransition(transition)); + } + + diskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, content); + return this._wrap(entry, entry.reconciler.diskSnapshot(content)); + } + + transfer(previousResource: URI, resource: URI): IUnifiedDocumentRegistryResult { + const canonicalPreviousResource = this._canonicalize(previousResource); + const canonicalResource = this._canonicalize(resource); + const previousKey = this._key(canonicalPreviousResource); + const key = this._key(canonicalResource); + const entry = this._entries.get(previousKey); + if (!entry) { + return { outcome: 'conflict', resource: canonicalResource }; + } + if (previousKey === key) { + entry.resource = canonicalResource; + return { outcome: 'duplicate', resource: canonicalResource }; + } + if (this._entries.has(key)) { + return { outcome: 'conflict', resource: canonicalResource }; + } + + this._entries.delete(previousKey); + entry.resource = canonicalResource; + this._entries.set(key, entry); + return { outcome: 'applied', resource: canonicalResource }; + } + + delete(resource: URI): boolean { + return this._entries.delete(this._key(resource)); + } + + clear(): void { + this._entries.clear(); + } + + private _getOrCreate(resource: URI, initialContent: string): UnifiedDocumentRegistryEntry { + const canonicalResource = this._canonicalize(resource); + const key = this._key(canonicalResource); + let entry = this._entries.get(key); + if (!entry) { + entry = new UnifiedDocumentRegistryEntry( + canonicalResource, + new UnifiedDocumentReconciler(initialContent, this._options.externalSource), + ); + this._entries.set(key, entry); + } + return entry; + } + + private _canonicalize(resource: URI): URI { + return this._options.canonicalize(resource); + } + + private _key(resource: URI): string { + return this._options.getComparisonKey(this._canonicalize(resource)); + } + + private _wrap( + entry: UnifiedDocumentRegistryEntry, + reconcileResult: IUnifiedDocumentReconcileResult, + ): IUnifiedDocumentRegistryResult { + return { + outcome: reconcileResult.outcome, + resource: entry.resource, + reconcileResult, + }; + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts new file mode 100644 index 00000000000000..b03c8b920c2162 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { UnifiedDocumentRegistry } from '../../browser/helpers/unifiedDocumentRegistry.js'; + +suite('UnifiedDocumentRegistry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses canonical resource identity', () => { + const registry = createRegistry(); + const first = URI.file('C:\\repo\\File.ts'); + const alias = URI.file('c:\\REPO\\file.ts'); + + registry.agentTransition(first, agentEdit('', 'content')); + + assert.strictEqual(registry.get(alias), registry.get(first)); + assert.strictEqual(registry.size, 1); + }); + + test('keeps matching remote paths on different authorities separate', () => { + const registry = createRegistry(); + const first = URI.from({ scheme: 'vscode-agent-host', authority: 'remote-one', path: '/repo/file.ts' }); + const second = URI.from({ scheme: 'vscode-agent-host', authority: 'remote-two', path: '/repo/file.ts' }); + + registry.diskSnapshot(first, 'one'); + registry.diskSnapshot(second, 'two'); + + assert.strictEqual(registry.size, 2); + assert.notStrictEqual(registry.get(first), registry.get(second)); + }); + + test('lazily creates a reconciler from an Agent Host transition', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + + const result = registry.agentTransition(resource, agentEdit('before', 'after')); + + assert.deepStrictEqual( + { + outcome: result.outcome, + resource: result.resource.toString(), + snapshot: registry.get(resource)?.reconciler.getSnapshot(), + }, + { + outcome: 'applied', + resource: URI.file('c:\\repo\\file.ts').toString(), + snapshot: { + initialContent: 'before', + content: 'after', + diskContent: 'after', + model: undefined, + pendingReload: undefined, + transitions: [{ + id: 1, + before: 'before', + after: 'after', + source: 'agent', + kind: 'agentHost', + correlation: 'agent-1', + agentKind: 'edit', + }], + }, + }, + ); + }); + + test('preserves one reconciler across model connect and disconnect', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + registry.agentTransition(resource, agentEdit('before', 'after')); + const reconciler = registry.get(resource)?.reconciler; + + assert.strictEqual(registry.modelConnected(resource, 'before', { content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(registry.modelDisconnected(resource).outcome, 'applied'); + assert.strictEqual(registry.modelConnected(resource, 'unused', { content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(registry.get(resource)?.reconciler, reconciler); + }); + + test('transfers reconciler identity across a rename', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\before.ts'); + const after = URI.file('C:\\repo\\after.ts'); + registry.agentTransition(before, agentEdit('content', 'content', 'rename-1', 'rename')); + const reconciler = registry.get(before)?.reconciler; + + const result = registry.transfer(before, after); + + assert.deepStrictEqual( + { + outcome: result.outcome, + oldEntry: registry.get(before), + sameReconciler: registry.get(after)?.reconciler === reconciler, + }, + { outcome: 'applied', oldEntry: undefined, sameReconciler: true }, + ); + }); + + test('rejects a rename onto an existing resource', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\before.ts'); + const after = URI.file('C:\\repo\\after.ts'); + registry.diskSnapshot(before, 'before'); + registry.diskSnapshot(after, 'after'); + + assert.strictEqual(registry.transfer(before, after).outcome, 'conflict'); + assert.strictEqual(registry.size, 2); + }); + + test('treats a canonical-only rename as a duplicate', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\File.ts'); + const after = URI.file('c:\\REPO\\file.ts'); + registry.diskSnapshot(before, 'content'); + + assert.strictEqual(registry.transfer(before, after).outcome, 'duplicate'); + assert.strictEqual(registry.size, 1); + assert.strictEqual(registry.get(after)?.resource.toString(), URI.file('c:\\repo\\file.ts').toString()); + }); + + test('reports model inputs for unknown resources explicitly', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + + assert.strictEqual(registry.modelDisconnected(resource).outcome, 'duplicate'); + assert.strictEqual(registry.modelEdit(resource, { + before: 'before', + after: 'after', + source: 'user', + kind: 'model', + dirty: true, + }).outcome, 'conflict'); + }); + + test('deletes and clears registry entries', () => { + const registry = createRegistry(); + const first = URI.file('C:\\repo\\first.ts'); + const second = URI.file('C:\\repo\\second.ts'); + registry.diskSnapshot(first, 'first'); + registry.diskSnapshot(second, 'second'); + + assert.strictEqual(registry.delete(first), true); + assert.strictEqual(registry.delete(first), false); + registry.clear(); + assert.strictEqual(registry.size, 0); + }); +}); + +function createRegistry(): UnifiedDocumentRegistry { + return new UnifiedDocumentRegistry({ + externalSource: 'external', + canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), + getComparisonKey: resource => resource.toString().toLowerCase(), + }); +} + +function agentEdit( + before: string, + after: string, + correlation = 'agent-1', + kind: 'create' | 'edit' | 'delete' | 'rename' = 'edit', +) { + return { + before, + after, + source: 'agent', + correlation, + kind, + } as const; +} From 39e4741a011d5e2fa41678310f1065a233d89f1b Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:15:06 -0700 Subject: [PATCH 16/21] Adapt Agent Host tracking to SCM interface Use the characterization seam introduced for SCM-trigger testing without depending on the concrete repository adapter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/telemetry/agentHostEditSourceTracking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 307dab52a95565..26b6bedef2c355 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -30,13 +30,13 @@ import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedE import { IRandomService } from '../randomService.js'; import { DocumentEditSourceTracker } from './editTracker.js'; import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; -import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; +import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; type ComputeDiff = (original: string, modified: string) => Promise; -type GetRepo = (resource: URI, reader: IReader) => ScmRepoAdapter | undefined; +type GetRepo = (resource: URI, reader: IReader) => IScmRepoAdapter | undefined; type SendDetails = (data: IEditSourcesDetailsTelemetryData, forwardToGitHub: boolean) => void; /** From d6f9913b6ca67ee2744435a218902c15b01bb7a5 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:13:12 -0700 Subject: [PATCH 17/21] reconciler for tracked edits --- .../helpers/unifiedDocumentAdapters.ts | 126 +++++++++++ .../helpers/unifiedDocumentReconciler.ts | 18 ++ .../unifiedDocumentTrackerProjection.ts | 153 +++++++++++++ .../browser/unifiedDocumentAdapters.test.ts | 209 ++++++++++++++++++ .../browser/unifiedDocumentReconciler.test.ts | 9 + .../unifiedDocumentTrackerProjection.test.ts | 208 +++++++++++++++++ 6 files changed, 723 insertions(+) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts new file mode 100644 index 00000000000000..dfdc16be6c15d3 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { runOnChange } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IObservableDocument, StringEditWithReason } from './observableWorkspace.js'; +import { + IUnifiedDocumentRegistryResult, + UnifiedDocumentRegistry, +} from './unifiedDocumentRegistry.js'; +import { UnifiedDocumentAgentTransitionKind } from './unifiedDocumentReconciler.js'; + +export type UnifiedDocumentModelAdapterInputKind = 'connected' | 'edit' | 'reloadFromDisk' | 'disconnected'; + +export interface IUnifiedDocumentModelAdapterResult { + readonly inputKind: UnifiedDocumentModelAdapterInputKind; + readonly result: IUnifiedDocumentRegistryResult; +} + +/** + * Translates one observable model into unified registry inputs. + */ +export class UnifiedDocumentModelAdapter extends Disposable { + private _isDisposed = false; + + constructor( + private readonly _registry: UnifiedDocumentRegistry, + private readonly _document: IObservableDocument, + initialDiskContent: string, + private readonly _isDirty: () => boolean, + private readonly _toSource: (change: StringEditWithReason) => TSource, + private readonly _onResult: (result: IUnifiedDocumentModelAdapterResult) => void, + ) { + super(); + this._onResult({ + inputKind: 'connected', + result: this._registry.modelConnected( + this._document.uri, + initialDiskContent, + { content: this._document.value.get().value, dirty: this._isDirty() }, + ), + }); + + this._register(runOnChange(this._document.value, (value, previousValue, changes) => { + let before = previousValue.value; + for (const change of changes) { + const after = change.apply(before); + const inputKind = change.reason.metadata.source === 'reloadFromDisk' ? 'reloadFromDisk' : 'edit'; + this._onResult({ + inputKind, + result: this._registry.modelEdit(this._document.uri, { + before, + after, + source: this._toSource(change), + kind: inputKind === 'reloadFromDisk' ? 'reloadFromDisk' : 'model', + dirty: this._isDirty(), + }), + }); + before = after; + } + if (before !== value.value) { + throw new Error(`Unified document model adapter produced ${JSON.stringify(before)}, expected ${JSON.stringify(value.value)}`); + } + })); + } + + override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + super.dispose(); + this._onResult({ + inputKind: 'disconnected', + result: this._registry.modelDisconnected(this._document.uri), + }); + } +} + +export interface IUnifiedDocumentAgentEdit { + readonly resource: URI; + readonly previousResource?: URI; + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly correlation: string; + readonly kind: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentAgentAdapterResult { + readonly transitionResult: IUnifiedDocumentRegistryResult; + readonly transferResult?: IUnifiedDocumentRegistryResult; +} + +/** + * Applies one normalized Agent Host edit to the unified registry. + */ +export function applyUnifiedDocumentAgentEdit( + registry: UnifiedDocumentRegistry, + edit: IUnifiedDocumentAgentEdit, +): IUnifiedDocumentAgentAdapterResult { + const transitionResource = edit.kind === 'rename' && edit.previousResource ? edit.previousResource : edit.resource; + const transitionResult = registry.agentTransition(transitionResource, { + before: edit.before, + after: edit.after, + source: edit.source, + correlation: edit.correlation, + kind: edit.kind, + }); + if ( + edit.kind !== 'rename' || + !edit.previousResource || + transitionResult.outcome === 'conflict' || + transitionResult.outcome === 'skippedDirty' + ) { + return { transitionResult }; + } + + return { + transitionResult, + transferResult: registry.transfer(edit.previousResource, edit.resource), + }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts index 5b27772089ff09..cc9d4e9719462f 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts @@ -94,6 +94,14 @@ export class UnifiedDocumentReconciler { if (state.dirty) { return this._result('skippedDirty'); } + const latestAgentTransition = this._findLatestTransition('agentHost'); + if ( + latestAgentTransition?.before === state.content && + latestAgentTransition.after === this._content && + this._diskContent === this._content + ) { + return this._result('applied'); + } if (state.content !== this._diskContent) { return this._result('conflict'); } @@ -319,6 +327,16 @@ export class UnifiedDocumentReconciler { return undefined; } + private _findLatestTransition(kind: UnifiedDocumentTransitionKind): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if (transition.kind === kind) { + return transition; + } + } + return undefined; + } + private _findExternalTransition(before: string, after: string): IUnifiedDocumentTransition | undefined { for (let index = this._transitions.length - 1; index >= 0; index--) { const transition = this._transitions[index]; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts new file mode 100644 index 00000000000000..adb94da76c4a83 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; +import { IUnifiedDocumentSnapshot } from '../helpers/unifiedDocumentReconciler.js'; +import { DocumentEditSourceTracker } from './editTracker.js'; + +export type UnifiedDocumentComputeDiff = (before: string, after: string) => Promise; + +export interface IEditTrackerSourceSnapshot { + readonly sourceKey: string; + readonly representativeKey: string; + readonly insertedCount: number; + readonly retainedCount: number; +} + +export interface IEditTrackerRangeSnapshot { + readonly start: number; + readonly endExclusive: number; + readonly sourceKey: string; +} + +export interface IEditTrackerSnapshot { + readonly content: string; + readonly targetContent: string; + readonly hasPendingReload: boolean; + readonly totalRetainedCount: number; + readonly sources: readonly IEditTrackerSourceSnapshot[]; + readonly ranges: readonly IEditTrackerRangeSnapshot[]; +} + +export interface IEditTrackerShadowComparison { + readonly equal: boolean; + readonly differences: readonly string[]; +} + +/** + * Replays a unified transition snapshot through the existing edit-source tracker. + */ +export async function projectUnifiedDocumentTracker( + snapshot: IUnifiedDocumentSnapshot, + computeDiff: UnifiedDocumentComputeDiff, +): Promise { + const store = new DisposableStore(); + try { + const document = store.add(new ProjectionDocument(snapshot.initialContent)); + const tracker = store.add(new DocumentEditSourceTracker(document, undefined)); + let content = snapshot.initialContent; + for (const transition of snapshot.transitions) { + if (transition.before !== content) { + throw new Error(`Unified transition ${transition.id} starts from ${JSON.stringify(transition.before)}, expected ${JSON.stringify(content)}`); + } + if (transition.before !== transition.after) { + const edit = await computeDiff(transition.before, transition.after); + document.apply(edit, transition.source); + } + content = transition.after; + } + await tracker.waitForQueue(); + + if (snapshot.pendingReload) { + if (snapshot.pendingReload.before !== content || snapshot.pendingReload.after !== snapshot.content) { + throw new Error('Unified pending reload does not connect projected and target content'); + } + } else if (content !== snapshot.content) { + throw new Error(`Unified transition replay produced ${JSON.stringify(content)}, expected ${JSON.stringify(snapshot.content)}`); + } + + return snapshotDocumentEditSourceTracker(tracker, content, snapshot.content, !!snapshot.pendingReload); + } finally { + store.dispose(); + } +} + +export function snapshotDocumentEditSourceTracker( + tracker: DocumentEditSourceTracker, + content: string, + targetContent = content, + hasPendingReload = false, +): IEditTrackerSnapshot { + const ranges = tracker.getTrackedRanges().map(range => ({ + start: range.range.start, + endExclusive: range.range.endExclusive, + sourceKey: range.sourceKey, + })); + const retainedByKey = new Map(); + for (const range of ranges) { + retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.endExclusive - range.start); + } + const sources = tracker.getAllKeys().map(sourceKey => ({ + sourceKey, + representativeKey: tracker.getRepresentative(sourceKey)?.toKey(Number.MAX_SAFE_INTEGER) ?? '', + insertedCount: tracker.getTotalInsertedCharactersCount(sourceKey), + retainedCount: retainedByKey.get(sourceKey) ?? 0, + })).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); + + return { + content, + targetContent, + hasPendingReload, + totalRetainedCount: ranges.reduce((sum, range) => sum + range.endExclusive - range.start, 0), + sources, + ranges, + }; +} + +export function compareEditTrackerSnapshots( + reference: IEditTrackerSnapshot, + candidate: IEditTrackerSnapshot, +): IEditTrackerShadowComparison { + const differences: string[] = []; + compareField('content', reference.content, candidate.content, differences); + compareField('targetContent', reference.targetContent, candidate.targetContent, differences); + compareField('hasPendingReload', reference.hasPendingReload, candidate.hasPendingReload, differences); + compareField('totalRetainedCount', reference.totalRetainedCount, candidate.totalRetainedCount, differences); + compareField('sources', reference.sources, candidate.sources, differences); + compareField('ranges', reference.ranges, candidate.ranges, differences); + return { equal: differences.length === 0, differences }; +} + +function compareField(field: string, reference: unknown, candidate: unknown, differences: string[]): void { + const referenceValue = JSON.stringify(reference); + const candidateValue = JSON.stringify(candidate); + if (referenceValue !== candidateValue) { + differences.push(`${field}: expected ${referenceValue}, got ${candidateValue}`); + } +} + +class ProjectionDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialContent: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts new file mode 100644 index 00000000000000..e8c53a1fafe25a --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { + applyUnifiedDocumentAgentEdit, + IUnifiedDocumentModelAdapterResult, + UnifiedDocumentModelAdapter, +} from '../../browser/helpers/unifiedDocumentAdapters.js'; +import { IObservableDocument, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { UnifiedDocumentRegistry } from '../../browser/helpers/unifiedDocumentRegistry.js'; + +suite('Unified Document Adapters', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('translates model lifecycle and attributed edits', () => { + const disposables = new DisposableStore(); + const registry = createRegistry(); + const document = disposables.add(new TestObservableDocument('before')); + let dirty = false; + const results: IUnifiedDocumentModelAdapterResult[] = []; + const adapter = disposables.add(new UnifiedDocumentModelAdapter( + registry, + document, + 'before', + () => dirty, + change => change.reason, + result => results.push(result), + )); + + dirty = true; + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.cursor({ kind: 'type' }))); + adapter.dispose(); + + assert.deepStrictEqual( + { + inputs: results.map(result => result.inputKind), + outcomes: results.map(result => result.result.outcome), + model: registry.get(document.uri)?.reconciler.getSnapshot().model, + sources: registry.get(document.uri)?.reconciler.getSnapshot().transitions.map(transition => transition.source.metadata.source), + }, + { + inputs: ['connected', 'edit', 'disconnected'], + outcomes: ['applied', 'applied', 'applied'], + model: undefined, + sources: ['cursor'], + }, + ); + disposables.dispose(); + }); + + test('deduplicates an Agent Host edit followed by model reload', () => { + const disposables = new DisposableStore(); + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + const agentResult = applyUnifiedDocumentAgentEdit(registry, { + resource, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + const document = disposables.add(new TestObservableDocument('before', resource)); + const results: IUnifiedDocumentModelAdapterResult[] = []; + disposables.add(new UnifiedDocumentModelAdapter( + registry, + document, + 'before', + () => false, + change => change.reason, + result => results.push(result), + )); + + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + + assert.deepStrictEqual( + { + agentOutcome: agentResult.transitionResult.outcome, + modelOutcomes: results.map(result => result.result.outcome), + transitions: registry.get(resource)?.reconciler.getSnapshot().transitions.map(transition => ({ + kind: transition.kind, + source: transition.source.metadata.source, + })), + }, + { + agentOutcome: 'applied', + modelOutcomes: ['applied', 'duplicate'], + transitions: [{ kind: 'agentHost', source: 'Chat.applyEdits' }], + }, + ); + disposables.dispose(); + }); + + test('transfers registry identity for Agent Host rename', () => { + const registry = createRegistry(); + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + registry.diskSnapshot(previousResource, 'content'); + const reconciler = registry.get(previousResource)?.reconciler; + + const result = applyUnifiedDocumentAgentEdit(registry, { + resource, + previousResource, + before: 'content', + after: 'content', + source: agentSource(), + correlation: 'rename-1', + kind: 'rename', + }); + + assert.deepStrictEqual( + { + transitionOutcome: result.transitionResult.outcome, + transferOutcome: result.transferResult?.outcome, + oldEntry: registry.get(previousResource), + sameReconciler: registry.get(resource)?.reconciler === reconciler, + }, + { + transitionOutcome: 'applied', + transferOutcome: 'applied', + oldEntry: undefined, + sameReconciler: true, + }, + ); + }); + + test('does not transfer a dirty rename conflict', () => { + const registry = createRegistry(); + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + registry.modelConnected(previousResource, 'content', { content: 'dirty content', dirty: true }); + + const result = applyUnifiedDocumentAgentEdit(registry, { + resource, + previousResource, + before: 'content', + after: 'content', + source: agentSource(), + correlation: 'rename-1', + kind: 'rename', + }); + + assert.deepStrictEqual( + { + transitionOutcome: result.transitionResult.outcome, + transferResult: result.transferResult, + oldEntryExists: !!registry.get(previousResource), + newEntry: registry.get(resource), + }, + { + transitionOutcome: 'skippedDirty', + transferResult: undefined, + oldEntryExists: true, + newEntry: undefined, + }, + ); + }); +}); + +class TestObservableDocument extends Disposable implements IObservableDocument { + private readonly _value: ISettableObservable; + readonly value: IObservableWithChange; + readonly version: IObservable; + readonly languageId: IObservable; + + constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + this.version = observableValue(this, 1); + this.languageId = observableValue(this, 'typescript'); + } + + apply(edit: StringEditWithReason): void { + this._value.set(edit.applyOnText(this._value.get()), undefined, edit); + } +} + +function createRegistry(): UnifiedDocumentRegistry { + return new UnifiedDocumentRegistry({ + externalSource: EditSources.reloadFromDisk(), + canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), + getComparisonKey: resource => resource.toString().toLowerCase(), + }); +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + trackingScope: 'agentHostAIOnly', + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts index 636bf560aba654..b97c027910b1e2 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts @@ -323,6 +323,15 @@ suite('UnifiedDocumentReconciler', () => { assert.deepStrictEqual(reconciler.getSnapshot().transitions, expectedAgentSnapshot('before', 'after').transitions); }); + test('connects a stale clean model after Agent Host and waits for reload', () => { + const reconciler = createReconciler('before'); + reconciler.agentTransition(agentEdit('before', 'after')); + + assert.strictEqual(reconciler.modelConnected({ content: 'before', dirty: false }).outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + test('reports conflicting state and correlation reuse', () => { const reconciler = createReconciler('before'); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts new file mode 100644 index 00000000000000..44547bd8c423b1 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; +import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; +import { + compareEditTrackerSnapshots, + IEditTrackerSnapshot, + projectUnifiedDocumentTracker, + snapshotDocumentEditSourceTracker, +} from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; + +suite('Unified Document Tracker Projection', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches an independently driven existing tracker', async () => { + const initialContent = 'a'; + const agent = agentSource(); + const user = EditSources.cursor({ kind: 'type' }); + const reconciler = new UnifiedDocumentReconciler(initialContent, EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: initialContent, dirty: false }); + reconciler.agentTransition({ + before: 'a', + after: 'ab', + source: agent, + correlation: 'tool-1', + kind: 'edit', + }); + reconciler.modelEdit({ + before: 'a', + after: 'ab', + source: EditSources.reloadFromDisk(), + kind: 'reloadFromDisk', + dirty: false, + }); + reconciler.modelEdit({ + before: 'ab', + after: 'abU', + source: user, + kind: 'model', + dirty: true, + }); + + const candidate = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + const reference = await createReferenceSnapshot(initialContent, [ + { before: 'a', after: 'ab', source: agent }, + { before: 'ab', after: 'abU', source: user }, + ]); + + assert.deepStrictEqual(compareEditTrackerSnapshots(reference, candidate), { equal: true, differences: [] }); + }); + + test('projects reload-first attribution only after Agent Host claims it', async () => { + const reconciler = new UnifiedDocumentReconciler('before', EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'after', + source: EditSources.reloadFromDisk(), + kind: 'reloadFromDisk', + dirty: false, + }); + + const pending = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + reconciler.agentTransition({ + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + const attributed = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + + assert.deepStrictEqual( + { + pending: { + content: pending.content, + targetContent: pending.targetContent, + hasPendingReload: pending.hasPendingReload, + sources: pending.sources, + }, + attributed: { + content: attributed.content, + targetContent: attributed.targetContent, + hasPendingReload: attributed.hasPendingReload, + sources: attributed.sources.map(source => ({ + sourceKey: source.sourceKey, + insertedCount: source.insertedCount, + retainedCount: source.retainedCount, + })), + }, + }, + { + pending: { + content: 'before', + targetContent: 'after', + hasPendingReload: true, + sources: [], + }, + attributed: { + content: 'after', + targetContent: 'after', + hasPendingReload: false, + sources: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + insertedCount: 5, + retainedCount: 5, + }], + }, + }, + ); + }); + + test('reports field-level shadow differences', () => { + const reference = emptySnapshot('reference'); + const candidate = { ...emptySnapshot('candidate'), totalRetainedCount: 1 }; + + assert.deepStrictEqual(compareEditTrackerSnapshots(reference, candidate), { + equal: false, + differences: [ + 'content: expected "reference", got "candidate"', + 'targetContent: expected "reference", got "candidate"', + 'totalRetainedCount: expected 0, got 1', + ], + }); + }); +}); + +async function createReferenceSnapshot( + initialContent: string, + transitions: readonly { before: string; after: string; source: TextModelEditSource }[], +): Promise { + const store = new DisposableStore(); + try { + const document = store.add(new ReferenceDocument(initialContent)); + const tracker = store.add(new DocumentEditSourceTracker(document, undefined)); + let content = initialContent; + for (const transition of transitions) { + assert.strictEqual(transition.before, content); + document.apply(await computeDiff(transition.before, transition.after), transition.source); + content = transition.after; + } + await tracker.waitForQueue(); + return snapshotDocumentEditSourceTracker(tracker, content); + } finally { + store.dispose(); + } +} + +function computeDiff(before: string, after: string): Promise { + return computeStringDiff(before, after, { maxComputationTimeMs: 500 }, 'advanced'); +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + trackingScope: 'agentHostAIOnly', + }); +} + +function emptySnapshot(content: string): IEditTrackerSnapshot { + return { + content, + targetContent: content, + hasPendingReload: false, + totalRetainedCount: 0, + sources: [], + ranges: [], + }; +} + +class ReferenceDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialContent: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} From c36b3da96b083e59523ede9a597b818d38ac1b84 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:34:47 -0700 Subject: [PATCH 18/21] Register unified edit tracker shadow Mirror open-model, Agent Host, and disk observations into the canonical registry without changing production telemetry ownership. Keep projection and snapshot comparison diagnostic-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../telemetry/agentHostEditSourceTracking.ts | 28 ++- .../telemetry/editSourceTrackingFeature.ts | 12 +- .../unifiedEditTrackerShadowTracking.ts | 124 ++++++++++++ .../unifiedEditTrackerShadowTracking.test.ts | 180 ++++++++++++++++++ 4 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 26b6bedef2c355..1defd174936a13 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -31,6 +31,7 @@ import { IRandomService } from '../randomService.js'; import { DocumentEditSourceTracker } from './editTracker.js'; import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; +import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; @@ -102,6 +103,7 @@ export class AgentHostTrackedFile extends Disposable { private readonly _sendDetails: SendDetails, private readonly _logService: ILogService, private readonly _onDidExpire: () => void, + private readonly _onDidReconcile?: (resource: URI, content: string) => void, ) { super(); this._resource = observableValue(this, resource); @@ -152,6 +154,7 @@ export class AgentHostTrackedFile extends Disposable { } await this._document.reconcile(currentText, this._computeDiff); + this._onDidReconcile?.(this.resource, currentText); const tracker = this._tracker.value; if (!tracker) { return; @@ -267,6 +270,7 @@ export class AgentHostEditSourceTracking extends Disposable { constructor( private readonly _detailsEnabled: IObservable, + private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IFileService private readonly _fileService: IFileService, @IEditorWorkerService editorWorkerService: IEditorWorkerService, @@ -312,9 +316,9 @@ export class AgentHostEditSourceTracking extends Disposable { if (!provider) { return; } - for (const content of action.result.content ?? []) { + for (const [contentIndex, content] of (action.result.content ?? []).entries()) { if (content.type === ToolResultContentType.FileEdit) { - await this._processFileEdit(connectionInfo.authority, session, provider, action.turnId, content); + await this._processFileEdit(connectionInfo.authority, session, provider, action.turnId, action.toolCallId, contentIndex, content); } } }); @@ -328,6 +332,8 @@ export class AgentHostEditSourceTracking extends Disposable { session: URI, provider: string, turnId: string, + toolCallId: string, + contentIndex: number, fileEdit: ToolResultFileEditContent, ): Promise { const normalized = normalizeFileEdit(fileEdit); @@ -343,7 +349,7 @@ export class AgentHostEditSourceTracking extends Disposable { .filter(resource => resource !== undefined) .map(resource => toAgentHostUri(resource, connectionAuthority)); const dirtyResource = editedResources.find(resource => isDirtyOpenTextModel(resource, this._modelService, this._textFileService)); - if (dirtyResource) { + if (dirtyResource && !this._shadowTracking) { this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); return; } @@ -369,9 +375,22 @@ export class AgentHostEditSourceTracking extends Disposable { origin: 'agentHost', trackingScope: AGENT_HOST_TRACKING_SCOPE, }); + const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; + this._shadowTracking?.applyAgentEdit({ + resource, + previousResource: beforeResource, + before: beforeText, + after: afterText, + source, + correlation: `${session.toString()}:${toolCallId}:${contentIndex}`, + kind: normalized.kind, + }); + if (dirtyResource) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); + return; + } const resourceKey = this._uriIdentityService.extUri.getComparisonKey(resource); - const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; const beforeResourceKey = beforeResource ? this._uriIdentityService.extUri.getComparisonKey(beforeResource) : undefined; let trackedFile = this._trackedFiles.get(resourceKey); if (!trackedFile && beforeResourceKey && beforeResourceKey !== resourceKey) { @@ -393,6 +412,7 @@ export class AgentHostEditSourceTracking extends Disposable { (data, forwardToGitHub) => sendEditSourcesDetailsTelemetry(this._telemetryService, data, forwardToGitHub), this._logService, () => this._removeTrackedFile(createdTrackedFile), + (currentResource, content) => this._shadowTracking?.applyDiskSnapshot(currentResource, content), ); trackedFile = createdTrackedFile; this._trackedFiles.set(resourceKey, trackedFile); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index 82d53b7fe6dc9e..c3e3e47a4ab2e6 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -28,6 +28,9 @@ import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDI import { VSCodeWorkspace } from '../helpers/vscodeObservableWorkspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { AgentHostEditSourceTracking } from './agentHostEditSourceTracking.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; export class EditTrackingFeature extends Disposable { @@ -45,6 +48,8 @@ export class EditTrackingFeature extends Disposable { @IEditorService private readonly _editorService: IEditorService, @IExtensionService private readonly _extensionService: IExtensionService, + @ITextFileService private readonly _textFileService: ITextFileService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -70,7 +75,12 @@ export class EditTrackingFeature extends Disposable { [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments)); - this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails)); + const unifiedShadowTracking = this._register(new UnifiedEditTrackerShadowTracking(this._workspace, { + isDirty: resource => this._textFileService.isDirty(resource), + canonicalize: resource => this._uriIdentityService.asCanonicalUri(resource), + getComparisonKey: resource => this._uriIdentityService.extUri.getComparisonKey(resource), + })); + this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails, unifiedShadowTracking)); this._register(autorun((reader) => { if (!this._editSourceTrackingShowDecorations.read(reader)) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts new file mode 100644 index 00000000000000..cf2e4b37a78df3 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { mapObservableArrayCached } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { TextModelEditSource, EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { ObservableWorkspace } from '../helpers/observableWorkspace.js'; +import { + applyUnifiedDocumentAgentEdit, + IUnifiedDocumentAgentAdapterResult, + IUnifiedDocumentAgentEdit, + UnifiedDocumentModelAdapter, +} from '../helpers/unifiedDocumentAdapters.js'; +import { + IUnifiedDocumentRegistryResult, + UnifiedDocumentRegistry, +} from '../helpers/unifiedDocumentRegistry.js'; +import { IUnifiedDocumentSnapshot } from '../helpers/unifiedDocumentReconciler.js'; +import { + compareEditTrackerSnapshots, + IEditTrackerShadowComparison, + IEditTrackerSnapshot, + projectUnifiedDocumentTracker, + UnifiedDocumentComputeDiff, +} from './unifiedDocumentTrackerProjection.js'; + +export interface IUnifiedEditTrackerShadowTrackingOptions { + readonly isDirty: (resource: URI) => boolean; + readonly canonicalize: (resource: URI) => URI; + readonly getComparisonKey: (resource: URI) => string; +} + +export interface IUnifiedEditTrackerShadowComparisonResult { + readonly candidate: IEditTrackerSnapshot; + readonly comparison: IEditTrackerShadowComparison; +} + +/** + * Mirrors live edit inputs into the unified registry without affecting production telemetry. + */ +export class UnifiedEditTrackerShadowTracking extends Disposable { + private readonly _registry: UnifiedDocumentRegistry; + private readonly _lastResults = new Map>(); + + constructor( + workspace: ObservableWorkspace, + private readonly _options: IUnifiedEditTrackerShadowTrackingOptions, + ) { + super(); + this._registry = new UnifiedDocumentRegistry({ + externalSource: EditSources.reloadFromDisk(), + canonicalize: this._options.canonicalize, + getComparisonKey: this._options.getComparisonKey, + }); + mapObservableArrayCached(this, workspace.documents, (document, store) => { + const initialContent = document.value.get().value; + return store.add(new UnifiedDocumentModelAdapter( + this._registry, + document, + initialContent, + () => this._options.isDirty(document.uri), + change => change.reason, + result => this._recordResult(result.result), + )); + }).recomputeInitiallyAndOnChange(this._store); + } + + applyAgentEdit(edit: IUnifiedDocumentAgentEdit): IUnifiedDocumentAgentAdapterResult { + const result = applyUnifiedDocumentAgentEdit(this._registry, edit); + this._recordResult(result.transitionResult); + if (result.transferResult) { + if (edit.previousResource) { + this._lastResults.delete(this._key(edit.previousResource)); + } + this._recordResult(result.transferResult); + } + return result; + } + + applyDiskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { + const result = this._registry.diskSnapshot(resource, content); + this._recordResult(result); + return result; + } + + getLastResult(resource: URI): IUnifiedDocumentRegistryResult | undefined { + return this._lastResults.get(this._key(resource)); + } + + getSnapshot(resource: URI): IUnifiedDocumentSnapshot | undefined { + return this._registry.get(resource)?.reconciler.getSnapshot(); + } + + async project(resource: URI, computeDiff: UnifiedDocumentComputeDiff): Promise { + const snapshot = this.getSnapshot(resource); + return snapshot ? projectUnifiedDocumentTracker(snapshot, computeDiff) : undefined; + } + + async compare( + resource: URI, + reference: IEditTrackerSnapshot, + computeDiff: UnifiedDocumentComputeDiff, + ): Promise { + const candidate = await this.project(resource, computeDiff); + if (!candidate) { + return undefined; + } + return { + candidate, + comparison: compareEditTrackerSnapshots(reference, candidate), + }; + } + + private _recordResult(result: IUnifiedDocumentRegistryResult): void { + this._lastResults.set(this._key(result.resource), result); + } + + private _key(resource: URI): string { + return this._options.getComparisonKey(this._options.canonicalize(resource)); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts new file mode 100644 index 00000000000000..1b34317b57c10e --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { IEditTrackerSnapshot } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; +import { UnifiedEditTrackerShadowTracking } from '../../browser/telemetry/unifiedEditTrackerShadowTracking.js'; + +suite('Unified Edit Tracker Shadow Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('mirrors model and Agent Host inputs without duplicating attribution', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = store.add(new TestObservableDocument('before')); + workspace.setDocuments([document]); + const shadow = store.add(createShadow(workspace, () => false)); + + const agentResult = shadow.applyAgentEdit({ + resource: document.uri, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + const candidate = await shadow.project(document.uri, computeDiff); + + assert.deepStrictEqual({ + agentOutcome: agentResult.transitionResult.outcome, + lastOutcome: shadow.getLastResult(document.uri)?.outcome, + content: candidate?.content, + hasPendingReload: candidate?.hasPendingReload, + sources: candidate?.sources.map(source => ({ + sourceKey: source.sourceKey, + insertedCount: source.insertedCount, + retainedCount: source.retainedCount, + })), + }, { + agentOutcome: 'applied', + lastOutcome: 'duplicate', + content: 'after', + hasPendingReload: false, + sources: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + insertedCount: 5, + retainedCount: 5, + }], + }); + store.dispose(); + }); + + test('tracks dirty skips and flush-time disk snapshots', () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = store.add(new TestObservableDocument('dirty')); + workspace.setDocuments([document]); + const shadow = store.add(createShadow(workspace, () => true)); + + const agentResult = shadow.applyAgentEdit({ + resource: document.uri, + before: 'disk', + after: 'agent', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + const diskResult = shadow.applyDiskSnapshot(document.uri, 'agent'); + + assert.deepStrictEqual({ + agentOutcome: agentResult.transitionResult.outcome, + diskOutcome: diskResult.outcome, + snapshot: shadow.getSnapshot(document.uri), + }, { + agentOutcome: 'skippedDirty', + diskOutcome: 'conflict', + snapshot: { + initialContent: 'dirty', + content: 'dirty', + diskContent: 'agent', + model: { content: 'dirty', dirty: true }, + pendingReload: undefined, + transitions: [], + }, + }); + store.dispose(); + }); + + test('compares a projected candidate on demand', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const shadow = store.add(createShadow(workspace, () => false)); + const resource = URI.file('C:\\repo\\file.ts'); + shadow.applyAgentEdit({ + resource, + before: '', + after: 'agent', + source: agentSource(), + correlation: 'tool-1', + kind: 'create', + }); + const candidate = await shadow.project(resource, computeDiff); + const reference: IEditTrackerSnapshot = { + ...candidate!, + totalRetainedCount: 0, + }; + const result = await shadow.compare(resource, reference, computeDiff); + + assert.deepStrictEqual(result?.comparison, { + equal: false, + differences: ['totalRetainedCount: expected 0, got 5'], + }); + store.dispose(); + }); +}); + +function createShadow(workspace: ObservableWorkspace, isDirty: (resource: URI) => boolean): UnifiedEditTrackerShadowTracking { + return new UnifiedEditTrackerShadowTracking(workspace, { + isDirty, + canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), + getComparisonKey: resource => resource.toString().toLowerCase(), + }); +} + +function computeDiff(before: string, after: string) { + return computeStringDiff(before, after, { maxComputationTimeMs: 500 }, 'advanced'); +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + trackingScope: 'agentHostAIOnly', + }); +} + +class TestWorkspace extends ObservableWorkspace { + private readonly _documents = observableValue(this, []); + override readonly documents: IObservable = this._documents; + + setDocuments(documents: readonly IObservableDocument[]): void { + this._documents.set(documents, undefined); + } +} + +class TestObservableDocument extends Disposable implements IObservableDocument { + private readonly _value: ISettableObservable; + readonly value: IObservableWithChange; + readonly version: IObservable; + readonly languageId: IObservable; + + constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + this.version = observableValue(this, 1); + this.languageId = observableValue(this, 'typescript'); + } + + apply(edit: StringEditWithReason): void { + this._value.set(edit.applyOnText(this._value.get()), undefined, edit); + } +} From 5ea1f417faf17efde28f8dd55a079b2e5b54bf81 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:49:10 -0700 Subject: [PATCH 19/21] Compare unified edit tracker shadow Capture local and Agent Host references at long-term flush boundaries, compare scoped tracker and details snapshots, and checkpoint repeated windows without changing telemetry ownership. Release closed shadow resources after queued comparisons finish. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../telemetry/agentHostEditSourceTracking.ts | 39 +++- .../telemetry/editSourceTrackingFeature.ts | 2 +- .../telemetry/editSourceTrackingImpl.ts | 48 ++++- .../unifiedDocumentTrackerProjection.ts | 135 ++++++++++++- .../unifiedEditTrackerShadowTracking.ts | 175 +++++++++++++++- .../agentHostEditSourceTracking.test.ts | 39 ++++ .../browser/editSourceTrackingImpl.test.ts | 2 +- .../test/browser/editTelemetry.test.ts | 2 +- .../unifiedDocumentTrackerProjection.test.ts | 51 +++++ .../unifiedEditTrackerShadowTracking.test.ts | 188 +++++++++++++++++- 10 files changed, 659 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 1defd174936a13..7c61d1177a6d29 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -32,6 +32,7 @@ import { DocumentEditSourceTracker } from './editTracker.js'; import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; +import { getEditTrackerComparisonDifferenceFields, IEditTrackerSnapshot, snapshotDocumentEditSourceTracker } from './unifiedDocumentTrackerProjection.js'; const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; @@ -103,7 +104,7 @@ export class AgentHostTrackedFile extends Disposable { private readonly _sendDetails: SendDetails, private readonly _logService: ILogService, private readonly _onDidExpire: () => void, - private readonly _onDidReconcile?: (resource: URI, content: string) => void, + private readonly _onDidFlush?: (resource: URI, content: string, reference: IEditTrackerSnapshot) => void, ) { super(); this._resource = observableValue(this, resource); @@ -154,14 +155,15 @@ export class AgentHostTrackedFile extends Disposable { } await this._document.reconcile(currentText, this._computeDiff); - this._onDidReconcile?.(this.resource, currentText); const tracker = this._tracker.value; if (!tracker) { return; } tracker.applyPendingExternalEdits(); + const reference = snapshotDocumentEditSourceTracker(tracker, currentText); this._sendTelemetry(trigger, tracker); this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); + this._onDidFlush?.(this.resource, currentText, reference); }); } @@ -412,15 +414,44 @@ export class AgentHostEditSourceTracking extends Disposable { (data, forwardToGitHub) => sendEditSourcesDetailsTelemetry(this._telemetryService, data, forwardToGitHub), this._logService, () => this._removeTrackedFile(createdTrackedFile), - (currentResource, content) => this._shadowTracking?.applyDiskSnapshot(currentResource, content), + (currentResource, content, reference) => this._compareAgentHostShadow(currentResource, content, reference), ); trackedFile = createdTrackedFile; this._trackedFiles.set(resourceKey, trackedFile); } + this._shadowTracking?.retainAgentResource(resource); await trackedFile.applyEdit(beforeText, afterText, source, languageId); } + private _compareAgentHostShadow(resource: URI, content: string, reference: IEditTrackerSnapshot): void { + const shadowTracking = this._shadowTracking; + if (!shadowTracking) { + return; + } + shadowTracking.applyDiskSnapshot(resource, content); + shadowTracking.compareAndCheckpoint( + 'agentHost', + resource, + reference, + (original, modified) => this._diffService.computeDiff(original, modified), + { sourceFilter: source => source.trackingScope === AGENT_HOST_TRACKING_SCOPE }, + ).then(result => { + if (!result) { + return; + } + const trackerFields = getEditTrackerComparisonDifferenceFields(result.trackerComparison); + const detailsFields = getEditTrackerComparisonDifferenceFields(result.detailsComparison); + if (trackerFields.length === 0 && detailsFields.length === 0) { + this._logService.trace('[AgentHostEditSourceTracking] Unified shadow comparison matched'); + } else { + this._logService.trace(`[AgentHostEditSourceTracking] Unified shadow comparison differed: tracker=${trackerFields.join(',')}; details=${detailsFields.join(',')}`); + } + }, () => { + this._logService.error('[AgentHostEditSourceTracking] Unified shadow comparison failed'); + }); + } + private async _readSnapshot(resource: URI, connectionAuthority: string): Promise { return this._readText(toAgentHostUri(resource, connectionAuthority), false); } @@ -461,6 +492,7 @@ export class AgentHostEditSourceTracking extends Disposable { for (const [key, value] of this._trackedFiles) { if (value === trackedFile) { this._trackedFiles.delete(key); + this._shadowTracking?.releaseAgentResource(trackedFile.resource); trackedFile.dispose(); return; } @@ -469,6 +501,7 @@ export class AgentHostEditSourceTracking extends Disposable { private _clearTrackedFiles(): void { for (const trackedFile of this._trackedFiles.values()) { + this._shadowTracking?.releaseAgentResource(trackedFile.resource); trackedFile.dispose(); } this._trackedFiles.clear(); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index c3e3e47a4ab2e6..8e9c518254fe03 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -74,12 +74,12 @@ export class EditTrackingFeature extends Disposable { const instantiationServiceWithInterceptedTelemetry = this._instantiationService.createChild(new ServiceCollection( [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); - const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments)); const unifiedShadowTracking = this._register(new UnifiedEditTrackerShadowTracking(this._workspace, { isDirty: resource => this._textFileService.isDirty(resource), canonicalize: resource => this._uriIdentityService.asCanonicalUri(resource), getComparisonKey: resource => this._uriIdentityService.extUri.getComparisonKey(resource), })); + const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments, unifiedShadowTracking)); this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails, unifiedShadowTracking)); this._register(autorun((reader) => { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 04ea1dce5f5684..47aea9f3f7bf76 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -12,12 +12,15 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js'; import { AnnotatedDocument, IAnnotatedDocuments } from '../helpers/annotatedDocuments.js'; import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArcForChatOrInlineChatSender, EditTelemetryReportInlineEditArcSender } from './arcTelemetrySender.js'; -import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; +import { createDocWithJustReason, DiffService, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; import { EditTelemetryMode, EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; +import { getEditTrackerComparisonDifferenceFields, snapshotDocumentEditSourceTracker, UnifiedDocumentComputeDiff } from './unifiedDocumentTrackerProjection.js'; export type EditTelemetryCategory = 'nes' | 'inlineCompletionsCopilot' | 'inlineCompletionsNES' | 'inlineCompletionsOther' | 'otherAI' | 'user' | 'ide' | 'external' | 'unknown'; @@ -43,13 +46,22 @@ export class EditSourceTrackingImpl extends Disposable { constructor( private readonly _statsEnabled: IObservable, private readonly _annotatedDocuments: IAnnotatedDocuments, + private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); const scmBridge = this._instantiationService.createInstance(ScmAdapter); + const diffService = this._instantiationService.createInstance(DiffService); this._states = mapObservableArrayCached(this, this._annotatedDocuments.documents, (doc, store) => { - return [doc.document, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, scmBridge, this._statsEnabled))] as const; + return [doc.document, store.add(this._instantiationService.createInstance( + TrackedDocumentInfo, + doc, + scmBridge, + this._statsEnabled, + this._shadowTracking, + (original, modified) => diffService.computeDiff(original, modified), + ))] as const; }); this.docsState = this._states.map((entries) => new Map(entries)); @@ -68,10 +80,13 @@ class TrackedDocumentInfo extends Disposable { private readonly _doc: AnnotatedDocument, private readonly _scm: ScmAdapter, private readonly _statsEnabled: IObservable, + private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, + private readonly _computeDiff: UnifiedDocumentComputeDiff, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRandomService private readonly _randomService: IRandomService, @IUserAttentionService private readonly _userAttentionService: IUserAttentionService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -86,10 +101,12 @@ class TrackedDocumentInfo extends Disposable { if (!this._statsEnabled.read(reader)) { return undefined; } longtermResetSignal.read(reader); + this._shadowTracking?.startComparison('local', this._doc.document.uri); const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined)); const startFocusTime = this._userAttentionService.totalFocusTimeMs; const startTime = Date.now(); reader.store.add(toDisposable(() => { + this._compareLongTermShadow(t); // send long term document telemetry if (!t.isEmpty()) { this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime); @@ -185,6 +202,33 @@ class TrackedDocumentInfo extends Disposable { } + private _compareLongTermShadow(tracker: DocumentEditSourceTracker): void { + const shadowTracking = this._shadowTracking; + if (!shadowTracking) { + return; + } + const reference = snapshotDocumentEditSourceTracker(tracker, this._doc.document.value.get().value); + shadowTracking.compareAndCheckpoint( + 'local', + this._doc.document.uri, + reference, + this._computeDiff, + { skipAgentHostTransitions: true, detailsOrder: 'retained' }, + ).then(result => { + if (!result) { + return; + } + const detailsFields = getEditTrackerComparisonDifferenceFields(result.detailsComparison); + if (detailsFields.length === 0) { + this._logService.trace('[EditSourceTrackingImpl] Unified long-term details shadow comparison matched'); + } else { + this._logService.trace(`[EditSourceTrackingImpl] Unified long-term details shadow comparison differed: ${detailsFields.join(',')}`); + } + }, () => { + this._logService.error('[EditSourceTrackingImpl] Unified long-term details shadow comparison failed'); + }); + } + async sendTelemetry(mode: EditTelemetryMode, trigger: EditTelemetryTrigger, t: DocumentEditSourceTracker, focusTime: number, actualTime: number) { const ranges = t.getTrackedRanges(); const keys = t.getAllKeys(); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts index adb94da76c4a83..7a86c094f9f07e 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts @@ -16,7 +16,18 @@ export type UnifiedDocumentComputeDiff = (before: string, after: string) => Prom export interface IEditTrackerSourceSnapshot { readonly sourceKey: string; + readonly sourceIndex: number; + readonly retainedIndex: number | undefined; readonly representativeKey: string; + readonly cleanedSourceKey: string; + readonly extensionId: string | undefined; + readonly extensionVersion: string | undefined; + readonly modelId: string | undefined; + readonly conversationId: string | undefined; + readonly requestId: string | undefined; + readonly origin: string | undefined; + readonly harness: string | undefined; + readonly trackingScope: string | undefined; readonly insertedCount: number; readonly retainedCount: number; } @@ -41,6 +52,29 @@ export interface IEditTrackerShadowComparison { readonly differences: readonly string[]; } +export interface IEditSourceDetailsRowSnapshot { + readonly sourceKey: string; + readonly cleanedSourceKey: string; + readonly extensionId: string | undefined; + readonly extensionVersion: string | undefined; + readonly modelId: string | undefined; + readonly conversationId: string | undefined; + readonly requestId: string | undefined; + readonly origin: string | undefined; + readonly harness: string | undefined; + readonly trackingScope: string | undefined; + readonly modifiedCount: number; + readonly deltaModifiedCount: number; +} + +export interface IEditSourceDetailsSnapshot { + readonly totalModifiedCount: number; + readonly rows: readonly IEditSourceDetailsRowSnapshot[]; +} + +export type EditTrackerSourceSnapshotFilter = (source: IEditTrackerSourceSnapshot) => boolean; +export type EditSourceDetailsOrder = 'tracker' | 'retained'; + /** * Replays a unified transition snapshot through the existing edit-source tracker. */ @@ -55,7 +89,7 @@ export async function projectUnifiedDocumentTracker( let content = snapshot.initialContent; for (const transition of snapshot.transitions) { if (transition.before !== content) { - throw new Error(`Unified transition ${transition.id} starts from ${JSON.stringify(transition.before)}, expected ${JSON.stringify(content)}`); + throw new Error(`Unified transition ${transition.id} starts from unexpected content`); } if (transition.before !== transition.after) { const edit = await computeDiff(transition.before, transition.after); @@ -70,7 +104,7 @@ export async function projectUnifiedDocumentTracker( throw new Error('Unified pending reload does not connect projected and target content'); } } else if (content !== snapshot.content) { - throw new Error(`Unified transition replay produced ${JSON.stringify(content)}, expected ${JSON.stringify(snapshot.content)}`); + throw new Error('Unified transition replay produced unexpected content'); } return snapshotDocumentEditSourceTracker(tracker, content, snapshot.content, !!snapshot.pendingReload); @@ -91,15 +125,33 @@ export function snapshotDocumentEditSourceTracker( sourceKey: range.sourceKey, })); const retainedByKey = new Map(); - for (const range of ranges) { + const retainedIndexByKey = new Map(); + for (const [rangeIndex, range] of ranges.entries()) { retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.endExclusive - range.start); + if (!retainedIndexByKey.has(range.sourceKey)) { + retainedIndexByKey.set(range.sourceKey, rangeIndex); + } } - const sources = tracker.getAllKeys().map(sourceKey => ({ - sourceKey, - representativeKey: tracker.getRepresentative(sourceKey)?.toKey(Number.MAX_SAFE_INTEGER) ?? '', - insertedCount: tracker.getTotalInsertedCharactersCount(sourceKey), - retainedCount: retainedByKey.get(sourceKey) ?? 0, - })).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); + const sources = tracker.getAllKeys().map((sourceKey, sourceIndex) => { + const representative = tracker.getRepresentative(sourceKey); + return { + sourceKey, + sourceIndex, + retainedIndex: retainedIndexByKey.get(sourceKey), + representativeKey: representative?.toKey(Number.MAX_SAFE_INTEGER) ?? '', + cleanedSourceKey: representative?.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }) ?? '', + extensionId: representative?.props.$extensionId, + extensionVersion: representative?.props.$extensionVersion, + modelId: representative?.props.$modelId, + conversationId: representative?.props.$$sessionId, + requestId: representative?.props.$$requestId, + origin: representative?.props.$origin, + harness: representative?.props.$harness, + trackingScope: representative?.props.$trackingScope, + insertedCount: tracker.getTotalInsertedCharactersCount(sourceKey), + retainedCount: retainedByKey.get(sourceKey) ?? 0, + }; + }).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); return { content, @@ -111,6 +163,57 @@ export function snapshotDocumentEditSourceTracker( }; } +export function filterEditTrackerSnapshot( + snapshot: IEditTrackerSnapshot, + filter: EditTrackerSourceSnapshotFilter, +): IEditTrackerSnapshot { + const sources = snapshot.sources + .filter(filter) + .sort((left, right) => left.sourceIndex - right.sourceIndex) + .map((source, sourceIndex) => ({ ...source, sourceIndex })) + .sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); + const sourceKeys = new Set(sources.map(source => source.sourceKey)); + const ranges = snapshot.ranges.filter(range => sourceKeys.has(range.sourceKey)); + return { + ...snapshot, + totalRetainedCount: ranges.reduce((sum, range) => sum + range.endExclusive - range.start, 0), + sources, + ranges, + }; +} + +export function snapshotEditSourceDetails( + snapshot: IEditTrackerSnapshot, + filter: EditTrackerSourceSnapshotFilter = () => true, + limit = 30, + order: EditSourceDetailsOrder = 'tracker', +): IEditSourceDetailsSnapshot { + const filteredSources = snapshot.sources.filter(filter); + const getOrder = (source: IEditTrackerSourceSnapshot) => order === 'retained' + ? source.retainedIndex ?? snapshot.ranges.length + source.sourceIndex + : source.sourceIndex; + const sources = filteredSources + .sort((left, right) => right.retainedCount - left.retainedCount || getOrder(left) - getOrder(right)) + .slice(0, limit); + return { + totalModifiedCount: filteredSources.reduce((sum, source) => sum + source.retainedCount, 0), + rows: sources.map(source => ({ + sourceKey: source.sourceKey, + cleanedSourceKey: source.cleanedSourceKey, + extensionId: source.extensionId, + extensionVersion: source.extensionVersion, + modelId: source.modelId, + conversationId: source.conversationId, + requestId: source.requestId, + origin: source.origin, + harness: source.harness, + trackingScope: source.trackingScope, + modifiedCount: source.retainedCount, + deltaModifiedCount: source.insertedCount, + })).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)), + }; +} + export function compareEditTrackerSnapshots( reference: IEditTrackerSnapshot, candidate: IEditTrackerSnapshot, @@ -125,6 +228,20 @@ export function compareEditTrackerSnapshots( return { equal: differences.length === 0, differences }; } +export function compareEditSourceDetailsSnapshots( + reference: IEditSourceDetailsSnapshot, + candidate: IEditSourceDetailsSnapshot, +): IEditTrackerShadowComparison { + const differences: string[] = []; + compareField('totalModifiedCount', reference.totalModifiedCount, candidate.totalModifiedCount, differences); + compareField('rows', reference.rows, candidate.rows, differences); + return { equal: differences.length === 0, differences }; +} + +export function getEditTrackerComparisonDifferenceFields(comparison: IEditTrackerShadowComparison): readonly string[] { + return comparison.differences.map(difference => difference.substring(0, difference.indexOf(':'))); +} + function compareField(field: string, reference: unknown, candidate: unknown, differences: string[]): void { const referenceValue = JSON.stringify(reference); const candidateValue = JSON.stringify(candidate); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts index cf2e4b37a78df3..3e6e1e1243f8bc 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts @@ -20,10 +20,16 @@ import { } from '../helpers/unifiedDocumentRegistry.js'; import { IUnifiedDocumentSnapshot } from '../helpers/unifiedDocumentReconciler.js'; import { + compareEditSourceDetailsSnapshots, compareEditTrackerSnapshots, + EditSourceDetailsOrder, + EditTrackerSourceSnapshotFilter, + filterEditTrackerSnapshot, + IEditSourceDetailsSnapshot, IEditTrackerShadowComparison, IEditTrackerSnapshot, projectUnifiedDocumentTracker, + snapshotEditSourceDetails, UnifiedDocumentComputeDiff, } from './unifiedDocumentTrackerProjection.js'; @@ -35,7 +41,21 @@ export interface IUnifiedEditTrackerShadowTrackingOptions { export interface IUnifiedEditTrackerShadowComparisonResult { readonly candidate: IEditTrackerSnapshot; - readonly comparison: IEditTrackerShadowComparison; + readonly trackerComparison: IEditTrackerShadowComparison; + readonly referenceDetails: IEditSourceDetailsSnapshot; + readonly candidateDetails: IEditSourceDetailsSnapshot; + readonly detailsComparison: IEditTrackerShadowComparison; +} + +export interface IUnifiedEditTrackerShadowComparisonOptions { + readonly sourceFilter?: EditTrackerSourceSnapshotFilter; + readonly skipAgentHostTransitions?: boolean; + readonly detailsOrder?: EditSourceDetailsOrder; +} + +interface IUnifiedEditTrackerShadowCheckpoint { + readonly content: string; + readonly maxTransitionId: number; } /** @@ -44,6 +64,9 @@ export interface IUnifiedEditTrackerShadowComparisonResult { export class UnifiedEditTrackerShadowTracking extends Disposable { private readonly _registry: UnifiedDocumentRegistry; private readonly _lastResults = new Map>(); + private readonly _checkpoints = new Map(); + private readonly _comparisonQueues = new Map>(); + private readonly _retainedAgentResources = new Set(); constructor( workspace: ObservableWorkspace, @@ -63,7 +86,12 @@ export class UnifiedEditTrackerShadowTracking extends Disposable { initialContent, () => this._options.isDirty(document.uri), change => change.reason, - result => this._recordResult(result.result), + result => { + this._recordResult(result.result); + if (result.inputKind === 'disconnected') { + this._scheduleCleanup(result.result.resource); + } + }, )); }).recomputeInitiallyAndOnChange(this._store); } @@ -74,12 +102,23 @@ export class UnifiedEditTrackerShadowTracking extends Disposable { if (result.transferResult) { if (edit.previousResource) { this._lastResults.delete(this._key(edit.previousResource)); + this._transferCheckpoints(edit.previousResource, edit.resource); + this._transferAgentRetention(edit.previousResource, edit.resource); } this._recordResult(result.transferResult); } return result; } + retainAgentResource(resource: URI): void { + this._retainedAgentResources.add(this._key(resource)); + } + + releaseAgentResource(resource: URI): void { + this._retainedAgentResources.delete(this._key(resource)); + this._scheduleCleanup(resource); + } + applyDiskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { const result = this._registry.diskSnapshot(resource, content); this._recordResult(result); @@ -99,6 +138,13 @@ export class UnifiedEditTrackerShadowTracking extends Disposable { return snapshot ? projectUnifiedDocumentTracker(snapshot, computeDiff) : undefined; } + startComparison(lane: string, resource: URI): void { + const snapshot = this.getSnapshot(resource); + if (snapshot) { + this._checkpoints.set(this._laneKey(lane, resource), createCheckpoint(snapshot)); + } + } + async compare( resource: URI, reference: IEditTrackerSnapshot, @@ -108,17 +154,140 @@ export class UnifiedEditTrackerShadowTracking extends Disposable { if (!candidate) { return undefined; } + const referenceDetails = snapshotEditSourceDetails(reference); + const candidateDetails = snapshotEditSourceDetails(candidate); return { candidate, - comparison: compareEditTrackerSnapshots(reference, candidate), + trackerComparison: compareEditTrackerSnapshots(reference, candidate), + referenceDetails, + candidateDetails, + detailsComparison: compareEditSourceDetailsSnapshots(referenceDetails, candidateDetails), + }; + } + + compareAndCheckpoint( + lane: string, + resource: URI, + reference: IEditTrackerSnapshot, + computeDiff: UnifiedDocumentComputeDiff, + options: IUnifiedEditTrackerShadowComparisonOptions = {}, + ): Promise { + const laneKey = this._laneKey(lane, resource); + const run = () => this._compareAndCheckpoint(laneKey, resource, reference, computeDiff, options); + const result = (this._comparisonQueues.get(laneKey) ?? Promise.resolve(undefined)).then(run, run); + this._comparisonQueues.set(laneKey, result); + const clearQueue = () => { + if (this._comparisonQueues.get(laneKey) === result) { + this._comparisonQueues.delete(laneKey); + } }; + result.then(clearQueue, clearQueue); + return result; + } + + private async _compareAndCheckpoint( + laneKey: string, + resource: URI, + reference: IEditTrackerSnapshot, + computeDiff: UnifiedDocumentComputeDiff, + options: IUnifiedEditTrackerShadowComparisonOptions, + ): Promise { + const snapshot = this.getSnapshot(resource); + if (!snapshot) { + return undefined; + } + const checkpoint = this._checkpoints.get(laneKey); + const transitions = checkpoint + ? snapshot.transitions.filter(transition => transition.id > checkpoint.maxTransitionId) + : snapshot.transitions; + const comparisonSnapshot: IUnifiedDocumentSnapshot = { + ...snapshot, + initialContent: checkpoint?.content ?? snapshot.initialContent, + transitions, + pendingReload: snapshot.pendingReload && (!checkpoint || snapshot.pendingReload.id > checkpoint.maxTransitionId) + ? snapshot.pendingReload + : undefined, + }; + if (options.skipAgentHostTransitions && transitions.some(transition => transition.kind === 'agentHost')) { + this._checkpoints.set(laneKey, createCheckpoint(snapshot)); + return undefined; + } + + let candidate = await projectUnifiedDocumentTracker(comparisonSnapshot, computeDiff); + let filteredReference = reference; + if (options.sourceFilter) { + candidate = filterEditTrackerSnapshot(candidate, options.sourceFilter); + filteredReference = filterEditTrackerSnapshot(reference, options.sourceFilter); + } + const referenceDetails = snapshotEditSourceDetails(filteredReference, undefined, 30, options.detailsOrder); + const candidateDetails = snapshotEditSourceDetails(candidate, undefined, 30, options.detailsOrder); + const result: IUnifiedEditTrackerShadowComparisonResult = { + candidate, + trackerComparison: compareEditTrackerSnapshots(filteredReference, candidate), + referenceDetails, + candidateDetails, + detailsComparison: compareEditSourceDetailsSnapshots(referenceDetails, candidateDetails), + }; + this._checkpoints.set(laneKey, createCheckpoint(snapshot)); + return result; } private _recordResult(result: IUnifiedDocumentRegistryResult): void { this._lastResults.set(this._key(result.resource), result); } + private _transferCheckpoints(previousResource: URI, resource: URI): void { + const previousKeySuffix = `:${this._key(previousResource)}`; + for (const [key, checkpoint] of this._checkpoints) { + if (key.endsWith(previousKeySuffix)) { + const lane = key.substring(0, key.length - previousKeySuffix.length); + this._checkpoints.delete(key); + this._checkpoints.set(this._laneKey(lane, resource), checkpoint); + } + } + } + + private _transferAgentRetention(previousResource: URI, resource: URI): void { + const previousKey = this._key(previousResource); + if (this._retainedAgentResources.delete(previousKey)) { + this._retainedAgentResources.add(this._key(resource)); + } + } + + private _scheduleCleanup(resource: URI): void { + const resourceKey = this._key(resource); + const keySuffix = `:${resourceKey}`; + const comparisons = Array.from(this._comparisonQueues) + .filter(([key]) => key.endsWith(keySuffix)) + .map(([, comparison]) => comparison); + Promise.allSettled(comparisons).then(() => { + const snapshot = this.getSnapshot(resource); + if (snapshot?.model || this._retainedAgentResources.has(resourceKey)) { + return; + } + this._registry.delete(resource); + this._lastResults.delete(resourceKey); + for (const key of this._checkpoints.keys()) { + if (key.endsWith(keySuffix)) { + this._checkpoints.delete(key); + } + } + }); + } + + private _laneKey(lane: string, resource: URI): string { + return `${lane}:${this._key(resource)}`; + } + private _key(resource: URI): string { return this._options.getComparisonKey(this._options.canonicalize(resource)); } } + +function createCheckpoint(snapshot: IUnifiedDocumentSnapshot): IUnifiedEditTrackerShadowCheckpoint { + let maxTransitionId = snapshot.pendingReload?.id ?? 0; + for (const transition of snapshot.transitions) { + maxTransitionId = Math.max(maxTransitionId, transition.id); + } + return { content: snapshot.content, maxTransitionId }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index b770256969d4e3..eb1ffe0bb33e95 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -104,6 +104,45 @@ suite('Agent Host Edit Source Tracking', () => { openDirty: true, }); }); + + test('provides a stable tracker snapshot at flush', async () => { + const disposables = new DisposableStore(); + let currentText = ''; + const snapshots: { content: string; sources: readonly { sourceKey: string; insertedCount: number; retainedCount: number }[] }[] = []; + const trackedFile = disposables.add(new AgentHostTrackedFile( + URI.file('C:\\repo\\file.ts'), + '', + async () => currentText, + async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + () => undefined, + () => 'stats-1', + () => { }, + new NullLogService(), + () => { }, + (_resource, content, snapshot) => snapshots.push({ + content, + sources: snapshot.sources.map(source => ({ + sourceKey: source.sourceKey, + insertedCount: source.insertedCount, + retainedCount: source.retainedCount, + })), + }), + )); + + currentText = 'agent'; + await trackedFile.applyEdit('', currentText, agentHostEditSource('copilotcli', 'session-1', 'turn-1'), 'typescript'); + await trackedFile.flush('hashChange'); + + assert.deepStrictEqual(snapshots, [{ + content: 'agent', + sources: [{ + sourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + insertedCount: 5, + retainedCount: 5, + }], + }]); + disposables.dispose(); + }); }); function agentHostEditSource(harness: string, sessionId: string, turnId: string) { diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts index f84b57dab64101..217d1e872bceca 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts @@ -208,7 +208,7 @@ function setup(visible: ISettableObservable = observableValue('visible' const workspace = new MutableObservableWorkspace(); const annotatedDocuments = disposables.add(new AnnotatedDocuments(workspace, instantiationService)); - const impl = disposables.add(new EditSourceTrackingImpl(constObservable(true), annotatedDocuments, instantiationService)); + const impl = disposables.add(new EditSourceTrackingImpl(constObservable(true), annotatedDocuments, undefined, instantiationService)); const document = disposables.add(workspace.createDocument({ uri: URI.file('C:\\repo\\file.ts'), initialValue: 'hello', diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts index ea95c818a8a343..babcbe065d865b 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts @@ -62,7 +62,7 @@ suite('Edit Telemetry', () => { const w = new MutableObservableWorkspace(); const docs = disposables.add(new AnnotatedDocuments(w, instantiationService)); - disposables.add(new EditSourceTrackingImpl(constObservable(true), docs, instantiationService)); + disposables.add(new EditSourceTrackingImpl(constObservable(true), docs, undefined, instantiationService)); const d1 = disposables.add(w.createDocument({ uri: URI.parse('file:///a'), initialValue: ` diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts index 44547bd8c423b1..69eeeecfa2a78b 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts @@ -16,9 +16,11 @@ import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocument import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; import { compareEditTrackerSnapshots, + filterEditTrackerSnapshot, IEditTrackerSnapshot, projectUnifiedDocumentTracker, snapshotDocumentEditSourceTracker, + snapshotEditSourceDetails, } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; suite('Unified Document Tracker Projection', () => { @@ -135,6 +137,55 @@ suite('Unified Document Tracker Projection', () => { ], }); }); + + test('projects details payload fields for a filtered source scope', async () => { + const initialContent = ''; + const agent = agentSource(); + const reconciler = new UnifiedDocumentReconciler(initialContent, EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: '', dirty: false }); + reconciler.modelEdit({ + before: '', + after: 'user', + source: EditSources.cursor({ kind: 'type' }), + kind: 'model', + dirty: true, + }); + reconciler.diskSnapshot('user'); + reconciler.agentTransition({ + before: 'user', + after: 'useragent', + source: agent, + correlation: 'tool-1', + kind: 'edit', + }); + + const projected = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + const filtered = filterEditTrackerSnapshot(projected, source => source.trackingScope === 'agentHostAIOnly'); + + assert.deepStrictEqual({ + sourceIndex: filtered.sources[0]?.sourceIndex, + details: snapshotEditSourceDetails(filtered), + }, { + sourceIndex: 0, + details: { + totalModifiedCount: 9, + rows: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + extensionId: undefined, + extensionVersion: undefined, + modelId: 'model', + conversationId: 'session', + requestId: 'request', + origin: 'agentHost', + harness: 'copilotcli', + trackingScope: 'agentHostAIOnly', + modifiedCount: 9, + deltaModifiedCount: 9, + }], + }, + }); + }); }); async function createReferenceSnapshot( diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts index 1b34317b57c10e..884ce2e27e0ca1 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts @@ -13,7 +13,8 @@ import { StringText } from '../../../../../editor/common/core/text/abstractText. import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; -import { IEditTrackerSnapshot } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; +import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; +import { IEditTrackerSnapshot, projectUnifiedDocumentTracker } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; import { UnifiedEditTrackerShadowTracking } from '../../browser/telemetry/unifiedEditTrackerShadowTracking.js'; suite('Unified Edit Tracker Shadow Tracking', () => { @@ -117,12 +118,160 @@ suite('Unified Edit Tracker Shadow Tracking', () => { }; const result = await shadow.compare(resource, reference, computeDiff); - assert.deepStrictEqual(result?.comparison, { + assert.deepStrictEqual(result?.trackerComparison, { equal: false, differences: ['totalRetainedCount: expected 0, got 5'], }); store.dispose(); }); + + test('checkpoints repeated Agent Host comparison windows', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const shadow = store.add(createShadow(workspace, () => false)); + const resource = URI.file('C:\\repo\\file.ts'); + shadow.applyAgentEdit({ + resource, + before: '', + after: 'one', + source: agentSource(), + correlation: 'tool-1', + kind: 'create', + }); + const firstReference = await projectSingleAgentTransition('', 'one', 'tool-1', 'create'); + const first = await shadow.compareAndCheckpoint( + 'agentHost', + resource, + firstReference, + computeDiff, + { sourceFilter: source => source.trackingScope === 'agentHostAIOnly' }, + ); + + shadow.applyAgentEdit({ + resource, + before: 'one', + after: 'two', + source: agentSource(), + correlation: 'tool-2', + kind: 'edit', + }); + const secondReference = await projectSingleAgentTransition('one', 'two', 'tool-2', 'edit'); + const second = await shadow.compareAndCheckpoint( + 'agentHost', + resource, + secondReference, + computeDiff, + { sourceFilter: source => source.trackingScope === 'agentHostAIOnly' }, + ); + + assert.deepStrictEqual({ + firstTracker: first?.trackerComparison, + firstDetails: first?.detailsComparison, + secondTracker: second?.trackerComparison, + secondDetails: second?.detailsComparison, + secondCandidateContent: second?.candidate.content, + secondInsertedCount: second?.candidate.sources[0]?.insertedCount, + }, { + firstTracker: { equal: true, differences: [] }, + firstDetails: { equal: true, differences: [] }, + secondTracker: { equal: true, differences: [] }, + secondDetails: { equal: true, differences: [] }, + secondCandidateContent: 'two', + secondInsertedCount: 3, + }); + store.dispose(); + }); + + test('checkpoints local windows containing Agent Host transitions without comparing them', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = store.add(new TestObservableDocument('before')); + workspace.setDocuments([document]); + const shadow = store.add(createShadow(workspace, () => false)); + shadow.startComparison('local', document.uri); + shadow.applyAgentEdit({ + resource: document.uri, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + const skipped = await shadow.compareAndCheckpoint( + 'local', + document.uri, + emptySnapshot('after'), + computeDiff, + { skipAgentHostTransitions: true }, + ); + + document.apply(StringEditWithReason.replace(new OffsetRange(5, 5), '!', EditSources.cursor({ kind: 'type' }))); + const userReference = await projectSingleModelTransition('after', 'after!'); + const compared = await shadow.compareAndCheckpoint( + 'local', + document.uri, + userReference, + computeDiff, + { skipAgentHostTransitions: true }, + ); + + assert.deepStrictEqual({ + skipped, + trackerComparison: compared?.trackerComparison, + detailsComparison: compared?.detailsComparison, + }, { + skipped: undefined, + trackerComparison: { equal: true, differences: [] }, + detailsComparison: { equal: true, differences: [] }, + }); + store.dispose(); + }); + + test('releases closed resources after queued comparisons complete', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = store.add(new TestObservableDocument('content')); + workspace.setDocuments([document]); + const shadow = store.add(createShadow(workspace, () => false)); + shadow.startComparison('local', document.uri); + const comparison = shadow.compareAndCheckpoint('local', document.uri, emptySnapshot('content'), computeDiff); + + workspace.setDocuments([]); + await comparison; + await Promise.resolve(); + + assert.strictEqual(shadow.getSnapshot(document.uri), undefined); + store.dispose(); + }); + + test('retains closed resources while an Agent Host tracker owns them', async () => { + const store = new DisposableStore(); + const workspace = new TestWorkspace(); + const shadow = store.add(createShadow(workspace, () => false)); + const resource = URI.file('C:\\repo\\file.ts'); + shadow.applyAgentEdit({ + resource, + before: '', + after: 'content', + source: agentSource(), + correlation: 'tool-1', + kind: 'create', + }); + shadow.retainAgentResource(resource); + const retained = !!shadow.getSnapshot(resource); + shadow.releaseAgentResource(resource); + await Promise.resolve(); + + assert.deepStrictEqual({ + retained, + afterRelease: shadow.getSnapshot(resource), + }, { + retained: true, + afterRelease: undefined, + }); + store.dispose(); + }); }); function createShadow(workspace: ObservableWorkspace, isDirty: (resource: URI) => boolean): UnifiedEditTrackerShadowTracking { @@ -152,6 +301,41 @@ function agentSource(): TextModelEditSource { }); } +async function projectSingleAgentTransition( + before: string, + after: string, + correlation: string, + kind: 'create' | 'edit', +): Promise { + const reconciler = new UnifiedDocumentReconciler(before, EditSources.reloadFromDisk()); + reconciler.agentTransition({ before, after, source: agentSource(), correlation, kind }); + return projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); +} + +async function projectSingleModelTransition(before: string, after: string): Promise { + const reconciler = new UnifiedDocumentReconciler(before, EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: before, dirty: false }); + reconciler.modelEdit({ + before, + after, + source: EditSources.cursor({ kind: 'type' }), + kind: 'model', + dirty: true, + }); + return projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); +} + +function emptySnapshot(content: string): IEditTrackerSnapshot { + return { + content, + targetContent: content, + hasPendingReload: false, + totalRetainedCount: 0, + sources: [], + ranges: [], + }; +} + class TestWorkspace extends ObservableWorkspace { private readonly _documents = observableValue(this, []); override readonly documents: IObservable = this._documents; From 78dec58e03c3da56b6efda83703a4a7479ab642e Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:26:55 -0700 Subject: [PATCH 20/21] Unify long-term edit source tracking Move long-term editSources.details ownership to one canonical per-resource stream shared by local models and Agent Host edits. Preserve local focus windows, stats, and ARC while removing the AH synthetic emitter and transitional shadow runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../telemetry/agentHostEditSourceTracking.ts | 217 ++--------- .../telemetry/editSourceTrackingFeature.ts | 16 +- .../telemetry/editSourceTrackingImpl.ts | 135 ++++--- .../unifiedDocumentTrackerProjection.ts | 10 +- .../telemetry/unifiedEditSourceTracking.ts | 296 ++++++++++++++ .../unifiedEditTrackerShadowTracking.ts | 293 -------------- .../agentHostEditSourceTracking.test.ts | 137 ++----- .../browser/unifiedDocumentAdapters.test.ts | 2 +- .../unifiedDocumentTrackerProjection.test.ts | 12 +- .../browser/unifiedEditSourceTracking.test.ts | 351 +++++++++++++++++ .../unifiedEditTrackerShadowTracking.test.ts | 364 ------------------ 11 files changed, 788 insertions(+), 1045 deletions(-) create mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts delete mode 100644 src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts create mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts delete mode 100644 src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 7c61d1177a6d29..68f5e251ac6139 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -6,14 +6,11 @@ import { IntervalTimer } from '../../../../../base/common/async.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { extname } from '../../../../../base/common/path.js'; -import { autorun, derived, IObservable, IObservableWithChange, IReader, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; -import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; -import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; @@ -22,72 +19,22 @@ import { ActionType } from '../../../../../platform/agentHost/common/state/proto import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ToolResultContentType, type ToolResultFileEditContent } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { ISCMService } from '../../../scm/common/scm.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { DiffService, EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; -import { IRandomService } from '../randomService.js'; -import { DocumentEditSourceTracker } from './editTracker.js'; -import { EditTelemetryTrigger, IEditSourcesDetailsTelemetryData, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { EditTelemetryTrigger } from './editSourceTelemetry.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; -import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; -import { getEditTrackerComparisonDifferenceFields, IEditTrackerSnapshot, snapshotDocumentEditSourceTracker } from './unifiedDocumentTrackerProjection.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; -const AGENT_HOST_TRACKING_SCOPE = 'agentHostAIOnly'; +const UNIFIED_TRACKING_SCOPE = 'unified'; -type ComputeDiff = (original: string, modified: string) => Promise; type GetRepo = (resource: URI, reader: IReader) => IScmRepoAdapter | undefined; -type SendDetails = (data: IEditSourcesDetailsTelemetryData, forwardToGitHub: boolean) => void; - -/** - * An in-memory document stream containing only Agent Host edits and reconciliation edits. - */ -class AgentHostSyntheticDocument extends Disposable implements IDocumentWithAnnotatedEdits { - private readonly _value: ISettableObservable }>; - readonly value: IObservableWithChange }>; - - constructor(initialText: string) { - super(); - this.value = this._value = observableValue(this, new StringText(initialText)); - } - - get text(): string { - return this._value.get().value; - } - - async applyTransition(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { - if (this.text !== beforeText) { - await this._apply(this.text, beforeText, EditSources.reloadFromDisk(), computeDiff); - } - await this._apply(beforeText, afterText, source, computeDiff); - } - - async reconcile(text: string, computeDiff: ComputeDiff): Promise { - await this._apply(this.text, text, EditSources.reloadFromDisk(), computeDiff); - } - - private async _apply(beforeText: string, afterText: string, source: TextModelEditSource, computeDiff: ComputeDiff): Promise { - if (beforeText === afterText) { - return; - } - const data = new EditSourceData(source).toEditSourceData(); - const edit = (await computeDiff(beforeText, afterText)).mapData(() => data); - this._value.set(new StringText(afterText), undefined, { edit }); - } - - waitForQueue(): Promise { - return Promise.resolve(); - } -} /** * Tracks long-term Agent Host AI attribution for one file. */ export class AgentHostTrackedFile extends Disposable { - private readonly _document: AgentHostSyntheticDocument; - private readonly _tracker = this._register(new MutableDisposable()); private readonly _resource: ISettableObservable; private readonly _repo; private _languageId = 'plaintext'; @@ -96,20 +43,14 @@ export class AgentHostTrackedFile extends Disposable { constructor( resource: URI, - initialText: string, private readonly _readCurrentText: (resource: URI) => Promise, - private readonly _computeDiff: ComputeDiff, getRepo: GetRepo, - private readonly _generateUuid: () => string, - private readonly _sendDetails: SendDetails, private readonly _logService: ILogService, private readonly _onDidExpire: () => void, - private readonly _onDidFlush?: (resource: URI, content: string, reference: IEditTrackerSnapshot) => void, + private readonly _onDidFlush: (resource: URI, content: string, languageId: string, trigger: EditTelemetryTrigger) => void, ) { super(); this._resource = observableValue(this, resource); - this._document = this._register(new AgentHostSyntheticDocument(initialText)); - this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); this._repo = derived(this, reader => getRepo(this._resource.read(reader), reader)); this._register(autorun(reader => { @@ -132,15 +73,12 @@ export class AgentHostTrackedFile extends Disposable { this._resource.set(resource, undefined); } - applyEdit(beforeText: string, afterText: string, source: TextModelEditSource, languageId: string): Promise { + applyEdit(languageId: string): Promise { return this._enqueue(async () => { if (this._isDisposed) { return; } - await this._document.applyTransition(beforeText, afterText, source, this._computeDiff); - if (!this._isDisposed) { - this._languageId = languageId; - } + this._languageId = languageId; }); } @@ -153,57 +91,10 @@ export class AgentHostTrackedFile extends Disposable { if (currentText === undefined || this._isDisposed) { return; } - - await this._document.reconcile(currentText, this._computeDiff); - const tracker = this._tracker.value; - if (!tracker) { - return; - } - tracker.applyPendingExternalEdits(); - const reference = snapshotDocumentEditSourceTracker(tracker, currentText); - this._sendTelemetry(trigger, tracker); - this._tracker.value = new DocumentEditSourceTracker(this._document, undefined); - this._onDidFlush?.(this.resource, currentText, reference); + this._onDidFlush(this.resource, currentText, this._languageId, trigger); }); } - private _sendTelemetry(trigger: EditTelemetryTrigger, tracker: DocumentEditSourceTracker): void { - const retainedByKey = new Map(); - let totalModifiedCount = 0; - for (const range of tracker.getTrackedRanges()) { - if (range.sourceRepresentative.props.$trackingScope !== AGENT_HOST_TRACKING_SCOPE) { - continue; - } - totalModifiedCount += range.range.length; - retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.range.length); - } - - const entries = tracker.getAllKeys() - .map(key => ({ key, representative: tracker.getRepresentative(key), modifiedCount: retainedByKey.get(key) ?? 0 })) - .filter(entry => entry.representative?.props.$trackingScope === AGENT_HOST_TRACKING_SCOPE) - .sort((a, b) => b.modifiedCount - a.modifiedCount) - .slice(0, 30); - if (entries.length === 0) { - return; - } - - const statsUuid = this._generateUuid(); - for (const entry of entries) { - const representative = entry.representative!; - sendEditSourcesDetailsTelemetryData( - this._sendDetails, - representative, - entry.key, - entry.modifiedCount, - tracker.getTotalInsertedCharactersCount(entry.key), - totalModifiedCount, - this._languageId, - statsUuid, - trigger, - ); - } - } - private _enqueue(operation: () => Promise): Promise { const result = this._operationQueue.then(operation, operation); this._operationQueue = result.then(() => undefined, () => undefined); @@ -226,67 +117,29 @@ export class AgentHostTrackedFile extends Disposable { } } -function sendEditSourcesDetailsTelemetryData( - sendDetails: SendDetails, - representative: TextModelEditSource, - sourceKey: string, - modifiedCount: number, - deltaModifiedCount: number, - totalModifiedCount: number, - languageId: string, - statsUuid: string, - trigger: EditTelemetryTrigger, -): void { - const harness = representative.props.$harness; - sendDetails({ - mode: 'longterm', - sourceKey, - sourceKeyCleaned: representative.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), - extensionId: representative.props.$extensionId, - extensionVersion: representative.props.$extensionVersion, - modelId: representative.props.$modelId, - trigger, - languageId, - statsUuid, - conversationId: representative.props.$$sessionId, - requestId: representative.props.$$requestId, - origin: representative.props.$origin, - harness, - trackingScope: representative.props.$trackingScope, - modifiedCount, - deltaModifiedCount, - totalModifiedCount, - }, harness === 'copilotcli'); -} - /** * Converts Agent Host file-edit actions into workbench edit-source telemetry. */ export class AgentHostEditSourceTracking extends Disposable { private readonly _connectionListeners = this._register(new MutableDisposable()); private readonly _trackedFiles = new Map(); - private readonly _diffService: DiffService; private readonly _scmAdapter: ScmAdapter; private _operationQueue: Promise = Promise.resolve(); private _isDisposed = false; constructor( private readonly _detailsEnabled: IObservable, - private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, + private readonly _unifiedTracking: UnifiedEditSourceTracking, @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IFileService private readonly _fileService: IFileService, - @IEditorWorkerService editorWorkerService: IEditorWorkerService, @IModelService private readonly _modelService: IModelService, @ILanguageService private readonly _languageService: ILanguageService, @ITextFileService private readonly _textFileService: ITextFileService, @ISCMService scmService: ISCMService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, - @IRandomService private readonly _randomService: IRandomService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILogService private readonly _logService: ILogService, ) { super(); - this._diffService = new DiffService(editorWorkerService); this._scmAdapter = new ScmAdapter(scmService); this._syncConnectionListeners(); this._register(this._connectionsService.onDidChangeConnections(() => this._syncConnectionListeners())); @@ -351,10 +204,6 @@ export class AgentHostEditSourceTracking extends Disposable { .filter(resource => resource !== undefined) .map(resource => toAgentHostUri(resource, connectionAuthority)); const dirtyResource = editedResources.find(resource => isDirtyOpenTextModel(resource, this._modelService, this._textFileService)); - if (dirtyResource && !this._shadowTracking) { - this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); - return; - } const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; @@ -375,10 +224,10 @@ export class AgentHostEditSourceTracking extends Disposable { codeBlockSuggestionId: undefined, harness, origin: 'agentHost', - trackingScope: AGENT_HOST_TRACKING_SCOPE, + trackingScope: UNIFIED_TRACKING_SCOPE, }); const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; - this._shadowTracking?.applyAgentEdit({ + this._unifiedTracking.applyAgentEdit({ resource, previousResource: beforeResource, before: beforeText, @@ -406,49 +255,27 @@ export class AgentHostEditSourceTracking extends Disposable { if (!trackedFile) { const createdTrackedFile = new AgentHostTrackedFile( resource, - beforeText, currentResource => this._readCurrentText(currentResource), - (original, modified) => this._diffService.computeDiff(original, modified), (repoResource, reader) => this._scmAdapter.getRepo(repoResource, reader), - () => this._randomService.generateUuid(), - (data, forwardToGitHub) => sendEditSourcesDetailsTelemetry(this._telemetryService, data, forwardToGitHub), this._logService, () => this._removeTrackedFile(createdTrackedFile), - (currentResource, content, reference) => this._compareAgentHostShadow(currentResource, content, reference), + (currentResource, content, currentLanguageId, trigger) => this._flushAgentHostResource(currentResource, content, currentLanguageId, trigger), ); trackedFile = createdTrackedFile; this._trackedFiles.set(resourceKey, trackedFile); } - this._shadowTracking?.retainAgentResource(resource); + this._unifiedTracking.retainAgentResource(resource); - await trackedFile.applyEdit(beforeText, afterText, source, languageId); + await trackedFile.applyEdit(languageId); } - private _compareAgentHostShadow(resource: URI, content: string, reference: IEditTrackerSnapshot): void { - const shadowTracking = this._shadowTracking; - if (!shadowTracking) { + private _flushAgentHostResource(resource: URI, content: string, languageId: string, trigger: EditTelemetryTrigger): void { + this._unifiedTracking.applyDiskSnapshot(resource, content); + if (this._unifiedTracking.hasLocalLongTermResource(resource)) { return; } - shadowTracking.applyDiskSnapshot(resource, content); - shadowTracking.compareAndCheckpoint( - 'agentHost', - resource, - reference, - (original, modified) => this._diffService.computeDiff(original, modified), - { sourceFilter: source => source.trackingScope === AGENT_HOST_TRACKING_SCOPE }, - ).then(result => { - if (!result) { - return; - } - const trackerFields = getEditTrackerComparisonDifferenceFields(result.trackerComparison); - const detailsFields = getEditTrackerComparisonDifferenceFields(result.detailsComparison); - if (trackerFields.length === 0 && detailsFields.length === 0) { - this._logService.trace('[AgentHostEditSourceTracking] Unified shadow comparison matched'); - } else { - this._logService.trace(`[AgentHostEditSourceTracking] Unified shadow comparison differed: tracker=${trackerFields.join(',')}; details=${detailsFields.join(',')}`); - } - }, () => { - this._logService.error('[AgentHostEditSourceTracking] Unified shadow comparison failed'); + this._unifiedTracking.flushLongTermDetails(resource, trigger, languageId).catch(error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to flush unified long-term details: ${error}`); }); } @@ -492,7 +319,7 @@ export class AgentHostEditSourceTracking extends Disposable { for (const [key, value] of this._trackedFiles) { if (value === trackedFile) { this._trackedFiles.delete(key); - this._shadowTracking?.releaseAgentResource(trackedFile.resource); + this._unifiedTracking.releaseAgentResource(trackedFile.resource); trackedFile.dispose(); return; } @@ -501,7 +328,7 @@ export class AgentHostEditSourceTracking extends Disposable { private _clearTrackedFiles(): void { for (const trackedFile of this._trackedFiles.values()) { - this._shadowTracking?.releaseAgentResource(trackedFile.resource); + this._unifiedTracking.releaseAgentResource(trackedFile.resource); trackedFile.dispose(); } this._trackedFiles.clear(); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index 8e9c518254fe03..ec289515446e43 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -28,9 +28,7 @@ import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDI import { VSCodeWorkspace } from '../helpers/vscodeObservableWorkspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { AgentHostEditSourceTracking } from './agentHostEditSourceTracking.js'; -import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; export class EditTrackingFeature extends Disposable { @@ -48,8 +46,6 @@ export class EditTrackingFeature extends Disposable { @IEditorService private readonly _editorService: IEditorService, @IExtensionService private readonly _extensionService: IExtensionService, - @ITextFileService private readonly _textFileService: ITextFileService, - @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -74,13 +70,9 @@ export class EditTrackingFeature extends Disposable { const instantiationServiceWithInterceptedTelemetry = this._instantiationService.createChild(new ServiceCollection( [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); - const unifiedShadowTracking = this._register(new UnifiedEditTrackerShadowTracking(this._workspace, { - isDirty: resource => this._textFileService.isDirty(resource), - canonicalize: resource => this._uriIdentityService.asCanonicalUri(resource), - getComparisonKey: resource => this._uriIdentityService.extUri.getComparisonKey(resource), - })); - const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments, unifiedShadowTracking)); - this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails, unifiedShadowTracking)); + const unifiedTracking = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(UnifiedEditSourceTracking, this._workspace)); + const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments, unifiedTracking)); + this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails, unifiedTracking)); this._register(autorun((reader) => { if (!this._editSourceTrackingShowDecorations.read(reader)) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 47aea9f3f7bf76..3d537eea0d970a 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -12,15 +12,14 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js'; import { AnnotatedDocument, IAnnotatedDocuments } from '../helpers/annotatedDocuments.js'; import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArcForChatOrInlineChatSender, EditTelemetryReportInlineEditArcSender } from './arcTelemetrySender.js'; -import { createDocWithJustReason, DiffService, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; +import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; import { EditTelemetryMode, EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { UnifiedEditTrackerShadowTracking } from './unifiedEditTrackerShadowTracking.js'; -import { getEditTrackerComparisonDifferenceFields, snapshotDocumentEditSourceTracker, UnifiedDocumentComputeDiff } from './unifiedDocumentTrackerProjection.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; export type EditTelemetryCategory = 'nes' | 'inlineCompletionsCopilot' | 'inlineCompletionsNES' | 'inlineCompletionsOther' | 'otherAI' | 'user' | 'ide' | 'external' | 'unknown'; @@ -46,21 +45,19 @@ export class EditSourceTrackingImpl extends Disposable { constructor( private readonly _statsEnabled: IObservable, private readonly _annotatedDocuments: IAnnotatedDocuments, - private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, + private readonly _unifiedTracking: UnifiedEditSourceTracking | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); const scmBridge = this._instantiationService.createInstance(ScmAdapter); - const diffService = this._instantiationService.createInstance(DiffService); this._states = mapObservableArrayCached(this, this._annotatedDocuments.documents, (doc, store) => { return [doc.document, store.add(this._instantiationService.createInstance( TrackedDocumentInfo, doc, scmBridge, this._statsEnabled, - this._shadowTracking, - (original, modified) => diffService.computeDiff(original, modified), + this._unifiedTracking, ))] as const; }); this.docsState = this._states.map((entries) => new Map(entries)); @@ -80,8 +77,7 @@ class TrackedDocumentInfo extends Disposable { private readonly _doc: AnnotatedDocument, private readonly _scm: ScmAdapter, private readonly _statsEnabled: IObservable, - private readonly _shadowTracking: UnifiedEditTrackerShadowTracking | undefined, - private readonly _computeDiff: UnifiedDocumentComputeDiff, + private readonly _unifiedTracking: UnifiedEditSourceTracking | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRandomService private readonly _randomService: IRandomService, @@ -91,6 +87,7 @@ class TrackedDocumentInfo extends Disposable { super(); this._repo = derived(this, reader => this._scm.getRepo(_doc.document.uri, reader)); + this._unifiedTracking?.retainLocalLongTermResource(this._doc.document.uri); const docWithJustReason = createDocWithJustReason(_doc.documentWithAnnotations, this._store); @@ -101,15 +98,15 @@ class TrackedDocumentInfo extends Disposable { if (!this._statsEnabled.read(reader)) { return undefined; } longtermResetSignal.read(reader); - this._shadowTracking?.startComparison('local', this._doc.document.uri); const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined)); const startFocusTime = this._userAttentionService.totalFocusTimeMs; const startTime = Date.now(); reader.store.add(toDisposable(() => { - this._compareLongTermShadow(t); + const statsUuid = this._randomService.generateUuid(); + this._flushLongTermDetails(longtermReason, statsUuid); // send long term document telemetry if (!t.isEmpty()) { - this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime); + this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime, statsUuid); } t.dispose(); })); @@ -202,34 +199,29 @@ class TrackedDocumentInfo extends Disposable { } - private _compareLongTermShadow(tracker: DocumentEditSourceTracker): void { - const shadowTracking = this._shadowTracking; - if (!shadowTracking) { + private _flushLongTermDetails(trigger: EditTelemetryTrigger, statsUuid: string): void { + const unifiedTracking = this._unifiedTracking; + if (!unifiedTracking) { return; } - const reference = snapshotDocumentEditSourceTracker(tracker, this._doc.document.value.get().value); - shadowTracking.compareAndCheckpoint( - 'local', + unifiedTracking.flushLongTermDetails( this._doc.document.uri, - reference, - this._computeDiff, - { skipAgentHostTransitions: true, detailsOrder: 'retained' }, - ).then(result => { - if (!result) { - return; - } - const detailsFields = getEditTrackerComparisonDifferenceFields(result.detailsComparison); - if (detailsFields.length === 0) { - this._logService.trace('[EditSourceTrackingImpl] Unified long-term details shadow comparison matched'); - } else { - this._logService.trace(`[EditSourceTrackingImpl] Unified long-term details shadow comparison differed: ${detailsFields.join(',')}`); - } - }, () => { - this._logService.error('[EditSourceTrackingImpl] Unified long-term details shadow comparison failed'); + trigger, + this._doc.document.languageId.get(), + statsUuid, + ).catch(error => { + this._logService.error(`[EditSourceTrackingImpl] Failed to flush unified long-term details: ${error}`); }); } - async sendTelemetry(mode: EditTelemetryMode, trigger: EditTelemetryTrigger, t: DocumentEditSourceTracker, focusTime: number, actualTime: number) { + async sendTelemetry( + mode: EditTelemetryMode, + trigger: EditTelemetryTrigger, + t: DocumentEditSourceTracker, + focusTime: number, + actualTime: number, + statsUuid = this._randomService.generateUuid(), + ) { const ranges = t.getTrackedRanges(); const keys = t.getAllKeys(); if (keys.length === 0) { @@ -238,42 +230,42 @@ class TrackedDocumentInfo extends Disposable { const data = this.getTelemetryData(ranges); - const statsUuid = this._randomService.generateUuid(); - - const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); - for (const key of keys) { - if (!sums[key]) { - sums[key] = 0; + if (mode !== 'longterm' || !this._unifiedTracking) { + const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); + for (const key of keys) { + if (!sums[key]) { + sums[key] = 0; + } + } + const entries = Object.entries(sums) + .filter((entry): entry is [string, number] => entry[1] !== undefined) + .sort(reverseOrder(compareBy(([, value]) => value, numberComparator))) + .slice(0, mode === 'longterm' ? 30 : 10); + + for (const [key, value] of entries) { + const repr = t.getRepresentative(key)!; + const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); + + sendEditSourcesDetailsTelemetry(this._telemetryService, { + mode, + sourceKey: key, + sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), + extensionId: repr.props.$extensionId, + extensionVersion: repr.props.$extensionVersion, + modelId: repr.props.$modelId, + trigger, + languageId: this._doc.document.languageId.get(), + statsUuid, + conversationId: repr.props.$$sessionId, + requestId: repr.props.$$requestId, + origin: repr.props.$origin, + harness: repr.props.$harness, + trackingScope: repr.props.$trackingScope, + modifiedCount: value, + deltaModifiedCount, + totalModifiedCount: data.totalModifiedCharactersInFinalState, + }); } - } - const entries = Object.entries(sums) - .filter((entry): entry is [string, number] => entry[1] !== undefined) - .sort(reverseOrder(compareBy(([, value]) => value, numberComparator))) - .slice(0, mode === 'longterm' ? 30 : 10); - - for (const [key, value] of entries) { - const repr = t.getRepresentative(key)!; - const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); - - sendEditSourcesDetailsTelemetry(this._telemetryService, { - mode, - sourceKey: key, - sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), - extensionId: repr.props.$extensionId, - extensionVersion: repr.props.$extensionVersion, - modelId: repr.props.$modelId, - trigger, - languageId: this._doc.document.languageId.get(), - statsUuid: statsUuid, - conversationId: repr.props.$$sessionId, - requestId: repr.props.$$requestId, - origin: repr.props.$origin, - harness: repr.props.$harness, - trackingScope: repr.props.$trackingScope, - modifiedCount: value, - deltaModifiedCount: deltaModifiedCount, - totalModifiedCount: data.totalModifiedCharactersInFinalState, - }); } @@ -336,6 +328,11 @@ class TrackedDocumentInfo extends Disposable { }); } + override dispose(): void { + super.dispose(); + this._unifiedTracking?.releaseLocalLongTermResource(this._doc.document.uri); + } + getTelemetryData(ranges: readonly TrackedEdit[]) { const sums = sumByCategory(ranges, r => r.range.length, r => getEditTelemetryCategory(r.source)); const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts index 7a86c094f9f07e..c500049791d89f 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts @@ -98,6 +98,7 @@ export async function projectUnifiedDocumentTracker( content = transition.after; } await tracker.waitForQueue(); + tracker.applyPendingExternalEdits(); if (snapshot.pendingReload) { if (snapshot.pendingReload.before !== content || snapshot.pendingReload.after !== snapshot.content) { @@ -210,7 +211,7 @@ export function snapshotEditSourceDetails( trackingScope: source.trackingScope, modifiedCount: source.retainedCount, deltaModifiedCount: source.insertedCount, - })).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)), + })), }; } @@ -234,7 +235,12 @@ export function compareEditSourceDetailsSnapshots( ): IEditTrackerShadowComparison { const differences: string[] = []; compareField('totalModifiedCount', reference.totalModifiedCount, candidate.totalModifiedCount, differences); - compareField('rows', reference.rows, candidate.rows, differences); + compareField( + 'rows', + reference.rows.toSorted((left, right) => left.sourceKey.localeCompare(right.sourceKey)), + candidate.rows.toSorted((left, right) => left.sourceKey.localeCompare(right.sourceKey)), + differences, + ); return { equal: differences.length === 0, differences }; } diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts new file mode 100644 index 00000000000000..da245935ada8bd --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts @@ -0,0 +1,296 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { mapObservableArrayCached } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { DiffService } from '../helpers/documentWithAnnotatedEdits.js'; +import { ObservableWorkspace } from '../helpers/observableWorkspace.js'; +import { + applyUnifiedDocumentAgentEdit, + IUnifiedDocumentAgentAdapterResult, + IUnifiedDocumentAgentEdit, + UnifiedDocumentModelAdapter, +} from '../helpers/unifiedDocumentAdapters.js'; +import { + IUnifiedDocumentRegistryResult, + UnifiedDocumentRegistry, +} from '../helpers/unifiedDocumentRegistry.js'; +import { IUnifiedDocumentSnapshot, IUnifiedDocumentTransition } from '../helpers/unifiedDocumentReconciler.js'; +import { IRandomService } from '../randomService.js'; +import { EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { + IEditSourceDetailsSnapshot, + IEditTrackerSnapshot, + projectUnifiedDocumentTracker, + snapshotEditSourceDetails, +} from './unifiedDocumentTrackerProjection.js'; + +interface IUnifiedEditSourceTrackingCheckpoint { + readonly content: string; + readonly maxTransitionId: number; +} + +/** + * Owns the canonical edit stream and long-term details windows for each resource. + */ +export class UnifiedEditSourceTracking extends Disposable { + private readonly _registry: UnifiedDocumentRegistry; + private readonly _lastResults = new Map>(); + private readonly _longTermCheckpoints = new Map(); + private readonly _flushQueues = new Map>(); + private readonly _retainedAgentResources = new Set(); + private readonly _localLongTermResources = new Set(); + private readonly _diffService: DiffService; + + constructor( + workspace: ObservableWorkspace, + @IFileService private readonly _fileService: IFileService, + @ITextFileService private readonly _textFileService: ITextFileService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @IRandomService private readonly _randomService: IRandomService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._diffService = new DiffService(editorWorkerService); + this._registry = new UnifiedDocumentRegistry({ + externalSource: EditSources.reloadFromDisk(), + canonicalize: resource => this._uriIdentityService.asCanonicalUri(resource), + getComparisonKey: resource => this._uriIdentityService.extUri.getComparisonKey(resource), + }); + mapObservableArrayCached(this, workspace.documents, (document, store) => { + const initialContent = document.value.get().value; + return store.add(new UnifiedDocumentModelAdapter( + this._registry, + document, + initialContent, + () => this._textFileService.isDirty(document.uri), + change => change.reason, + result => { + this._recordResult(result.result); + if (result.inputKind === 'disconnected') { + this._scheduleCleanup(result.result.resource); + } + }, + )); + }).recomputeInitiallyAndOnChange(this._store); + } + + applyAgentEdit(edit: IUnifiedDocumentAgentEdit): IUnifiedDocumentAgentAdapterResult { + const result = applyUnifiedDocumentAgentEdit(this._registry, edit); + this._recordResult(result.transitionResult); + if (result.transferResult) { + if (edit.previousResource) { + this._lastResults.delete(this._key(edit.previousResource)); + this._transferResourceState(edit.previousResource, edit.resource); + } + this._recordResult(result.transferResult); + } + return result; + } + + retainAgentResource(resource: URI): void { + this._retainedAgentResources.add(this._key(resource)); + } + + releaseAgentResource(resource: URI): void { + this._retainedAgentResources.delete(this._key(resource)); + this._scheduleCleanup(resource); + } + + retainLocalLongTermResource(resource: URI): void { + this._localLongTermResources.add(this._key(resource)); + } + + releaseLocalLongTermResource(resource: URI): void { + this._localLongTermResources.delete(this._key(resource)); + this._scheduleCleanup(resource); + } + + hasLocalLongTermResource(resource: URI): boolean { + return this._localLongTermResources.has(this._key(resource)); + } + + applyDiskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { + const result = this._registry.diskSnapshot(resource, content); + this._recordResult(result); + return result; + } + + getLastResult(resource: URI): IUnifiedDocumentRegistryResult | undefined { + return this._lastResults.get(this._key(resource)); + } + + getSnapshot(resource: URI): IUnifiedDocumentSnapshot | undefined { + return this._registry.get(resource)?.reconciler.getSnapshot(); + } + + async project(resource: URI): Promise { + const snapshot = this.getSnapshot(resource); + return snapshot ? projectUnifiedDocumentTracker(snapshot, (before, after) => this._diffService.computeDiff(before, after)) : undefined; + } + + flushLongTermDetails( + resource: URI, + trigger: EditTelemetryTrigger, + languageId: string, + statsUuid = this._randomService.generateUuid(), + ): Promise { + const resourceKey = this._key(resource); + const run = () => this._flushLongTermDetails(resourceKey, resource, trigger, languageId, statsUuid); + const result = (this._flushQueues.get(resourceKey) ?? Promise.resolve(undefined)).then(run, run); + this._flushQueues.set(resourceKey, result); + const clearQueue = () => { + if (this._flushQueues.get(resourceKey) === result) { + this._flushQueues.delete(resourceKey); + } + }; + result.then(clearQueue, clearQueue); + return result; + } + + private async _flushLongTermDetails( + resourceKey: string, + resource: URI, + trigger: EditTelemetryTrigger, + languageId: string, + statsUuid: string, + ): Promise { + await this._synchronizeDisk(resource); + const snapshot = this.getSnapshot(resource); + if (!snapshot) { + return undefined; + } + const checkpoint = this._longTermCheckpoints.get(resourceKey); + const transitions = checkpoint + ? snapshot.transitions.filter(transition => transition.id > checkpoint.maxTransitionId) + : snapshot.transitions; + const pendingReload = snapshot.pendingReload && (!checkpoint || snapshot.pendingReload.id > checkpoint.maxTransitionId) + ? snapshot.pendingReload + : undefined; + if (transitions.length === 0) { + if (!pendingReload) { + this._longTermCheckpoints.set(resourceKey, createCheckpoint(snapshot.content, snapshot.transitions)); + } + return undefined; + } + + const windowSnapshot: IUnifiedDocumentSnapshot = { + ...snapshot, + initialContent: checkpoint?.content ?? snapshot.initialContent, + transitions, + pendingReload, + }; + const trackerSnapshot = await projectUnifiedDocumentTracker( + windowSnapshot, + (before, after) => this._diffService.computeDiff(before, after), + ); + const details = snapshotEditSourceDetails(trackerSnapshot, undefined, 30, 'retained'); + if (details.rows.length > 0) { + for (const row of details.rows) { + sendEditSourcesDetailsTelemetry(this._telemetryService, { + mode: 'longterm', + sourceKey: row.sourceKey, + sourceKeyCleaned: row.cleanedSourceKey, + extensionId: row.extensionId, + extensionVersion: row.extensionVersion, + modelId: row.modelId, + trigger, + languageId, + statsUuid, + conversationId: row.conversationId, + requestId: row.requestId, + origin: row.origin, + harness: row.harness, + trackingScope: row.trackingScope, + modifiedCount: row.modifiedCount, + deltaModifiedCount: row.deltaModifiedCount, + totalModifiedCount: details.totalModifiedCount, + }, row.origin === 'agentHost' ? row.harness === 'copilotcli' : undefined); + } + } + this._longTermCheckpoints.set(resourceKey, createCheckpoint(trackerSnapshot.content, transitions)); + return details; + } + + private async _synchronizeDisk(resource: URI): Promise { + const snapshot = this.getSnapshot(resource); + if (snapshot?.model?.dirty || snapshot?.pendingReload || !this._fileService.hasProvider(resource)) { + return; + } + try { + const content = (await this._fileService.readFile(resource)).value.toString(); + if (!content.includes('\0')) { + this.applyDiskSnapshot(resource, content); + } + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + this.applyDiskSnapshot(resource, ''); + return; + } + throw error; + } + } + + private _recordResult(result: IUnifiedDocumentRegistryResult): void { + this._lastResults.set(this._key(result.resource), result); + } + + private _transferResourceState(previousResource: URI, resource: URI): void { + const previousKey = this._key(previousResource); + const key = this._key(resource); + const checkpoint = this._longTermCheckpoints.get(previousKey); + if (checkpoint) { + this._longTermCheckpoints.delete(previousKey); + this._longTermCheckpoints.set(key, checkpoint); + } + if (this._retainedAgentResources.delete(previousKey)) { + this._retainedAgentResources.add(key); + } + if (this._localLongTermResources.delete(previousKey)) { + this._localLongTermResources.add(key); + } + } + + private _scheduleCleanup(resource: URI): void { + const resourceKey = this._key(resource); + const pendingFlush = this._flushQueues.get(resourceKey); + (pendingFlush ?? Promise.resolve(undefined)).then(() => { + const snapshot = this.getSnapshot(resource); + if (snapshot?.model || this._retainedAgentResources.has(resourceKey) || this._localLongTermResources.has(resourceKey)) { + return; + } + this._registry.delete(resource); + this._lastResults.delete(resourceKey); + this._longTermCheckpoints.delete(resourceKey); + }, error => { + this._logService.error(`[UnifiedEditSourceTracking] Failed to finish resource cleanup: ${error}`); + }); + } + + private _key(resource: URI): string { + return this._uriIdentityService.extUri.getComparisonKey(this._uriIdentityService.asCanonicalUri(resource)); + } +} + +function createCheckpoint( + content: string, + transitions: readonly IUnifiedDocumentTransition[], +): IUnifiedEditSourceTrackingCheckpoint { + let maxTransitionId = 0; + for (const transition of transitions) { + maxTransitionId = Math.max(maxTransitionId, transition.id); + } + return { content, maxTransitionId }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts deleted file mode 100644 index 3e6e1e1243f8bc..00000000000000 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditTrackerShadowTracking.ts +++ /dev/null @@ -1,293 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { mapObservableArrayCached } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { TextModelEditSource, EditSources } from '../../../../../editor/common/textModelEditSource.js'; -import { ObservableWorkspace } from '../helpers/observableWorkspace.js'; -import { - applyUnifiedDocumentAgentEdit, - IUnifiedDocumentAgentAdapterResult, - IUnifiedDocumentAgentEdit, - UnifiedDocumentModelAdapter, -} from '../helpers/unifiedDocumentAdapters.js'; -import { - IUnifiedDocumentRegistryResult, - UnifiedDocumentRegistry, -} from '../helpers/unifiedDocumentRegistry.js'; -import { IUnifiedDocumentSnapshot } from '../helpers/unifiedDocumentReconciler.js'; -import { - compareEditSourceDetailsSnapshots, - compareEditTrackerSnapshots, - EditSourceDetailsOrder, - EditTrackerSourceSnapshotFilter, - filterEditTrackerSnapshot, - IEditSourceDetailsSnapshot, - IEditTrackerShadowComparison, - IEditTrackerSnapshot, - projectUnifiedDocumentTracker, - snapshotEditSourceDetails, - UnifiedDocumentComputeDiff, -} from './unifiedDocumentTrackerProjection.js'; - -export interface IUnifiedEditTrackerShadowTrackingOptions { - readonly isDirty: (resource: URI) => boolean; - readonly canonicalize: (resource: URI) => URI; - readonly getComparisonKey: (resource: URI) => string; -} - -export interface IUnifiedEditTrackerShadowComparisonResult { - readonly candidate: IEditTrackerSnapshot; - readonly trackerComparison: IEditTrackerShadowComparison; - readonly referenceDetails: IEditSourceDetailsSnapshot; - readonly candidateDetails: IEditSourceDetailsSnapshot; - readonly detailsComparison: IEditTrackerShadowComparison; -} - -export interface IUnifiedEditTrackerShadowComparisonOptions { - readonly sourceFilter?: EditTrackerSourceSnapshotFilter; - readonly skipAgentHostTransitions?: boolean; - readonly detailsOrder?: EditSourceDetailsOrder; -} - -interface IUnifiedEditTrackerShadowCheckpoint { - readonly content: string; - readonly maxTransitionId: number; -} - -/** - * Mirrors live edit inputs into the unified registry without affecting production telemetry. - */ -export class UnifiedEditTrackerShadowTracking extends Disposable { - private readonly _registry: UnifiedDocumentRegistry; - private readonly _lastResults = new Map>(); - private readonly _checkpoints = new Map(); - private readonly _comparisonQueues = new Map>(); - private readonly _retainedAgentResources = new Set(); - - constructor( - workspace: ObservableWorkspace, - private readonly _options: IUnifiedEditTrackerShadowTrackingOptions, - ) { - super(); - this._registry = new UnifiedDocumentRegistry({ - externalSource: EditSources.reloadFromDisk(), - canonicalize: this._options.canonicalize, - getComparisonKey: this._options.getComparisonKey, - }); - mapObservableArrayCached(this, workspace.documents, (document, store) => { - const initialContent = document.value.get().value; - return store.add(new UnifiedDocumentModelAdapter( - this._registry, - document, - initialContent, - () => this._options.isDirty(document.uri), - change => change.reason, - result => { - this._recordResult(result.result); - if (result.inputKind === 'disconnected') { - this._scheduleCleanup(result.result.resource); - } - }, - )); - }).recomputeInitiallyAndOnChange(this._store); - } - - applyAgentEdit(edit: IUnifiedDocumentAgentEdit): IUnifiedDocumentAgentAdapterResult { - const result = applyUnifiedDocumentAgentEdit(this._registry, edit); - this._recordResult(result.transitionResult); - if (result.transferResult) { - if (edit.previousResource) { - this._lastResults.delete(this._key(edit.previousResource)); - this._transferCheckpoints(edit.previousResource, edit.resource); - this._transferAgentRetention(edit.previousResource, edit.resource); - } - this._recordResult(result.transferResult); - } - return result; - } - - retainAgentResource(resource: URI): void { - this._retainedAgentResources.add(this._key(resource)); - } - - releaseAgentResource(resource: URI): void { - this._retainedAgentResources.delete(this._key(resource)); - this._scheduleCleanup(resource); - } - - applyDiskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { - const result = this._registry.diskSnapshot(resource, content); - this._recordResult(result); - return result; - } - - getLastResult(resource: URI): IUnifiedDocumentRegistryResult | undefined { - return this._lastResults.get(this._key(resource)); - } - - getSnapshot(resource: URI): IUnifiedDocumentSnapshot | undefined { - return this._registry.get(resource)?.reconciler.getSnapshot(); - } - - async project(resource: URI, computeDiff: UnifiedDocumentComputeDiff): Promise { - const snapshot = this.getSnapshot(resource); - return snapshot ? projectUnifiedDocumentTracker(snapshot, computeDiff) : undefined; - } - - startComparison(lane: string, resource: URI): void { - const snapshot = this.getSnapshot(resource); - if (snapshot) { - this._checkpoints.set(this._laneKey(lane, resource), createCheckpoint(snapshot)); - } - } - - async compare( - resource: URI, - reference: IEditTrackerSnapshot, - computeDiff: UnifiedDocumentComputeDiff, - ): Promise { - const candidate = await this.project(resource, computeDiff); - if (!candidate) { - return undefined; - } - const referenceDetails = snapshotEditSourceDetails(reference); - const candidateDetails = snapshotEditSourceDetails(candidate); - return { - candidate, - trackerComparison: compareEditTrackerSnapshots(reference, candidate), - referenceDetails, - candidateDetails, - detailsComparison: compareEditSourceDetailsSnapshots(referenceDetails, candidateDetails), - }; - } - - compareAndCheckpoint( - lane: string, - resource: URI, - reference: IEditTrackerSnapshot, - computeDiff: UnifiedDocumentComputeDiff, - options: IUnifiedEditTrackerShadowComparisonOptions = {}, - ): Promise { - const laneKey = this._laneKey(lane, resource); - const run = () => this._compareAndCheckpoint(laneKey, resource, reference, computeDiff, options); - const result = (this._comparisonQueues.get(laneKey) ?? Promise.resolve(undefined)).then(run, run); - this._comparisonQueues.set(laneKey, result); - const clearQueue = () => { - if (this._comparisonQueues.get(laneKey) === result) { - this._comparisonQueues.delete(laneKey); - } - }; - result.then(clearQueue, clearQueue); - return result; - } - - private async _compareAndCheckpoint( - laneKey: string, - resource: URI, - reference: IEditTrackerSnapshot, - computeDiff: UnifiedDocumentComputeDiff, - options: IUnifiedEditTrackerShadowComparisonOptions, - ): Promise { - const snapshot = this.getSnapshot(resource); - if (!snapshot) { - return undefined; - } - const checkpoint = this._checkpoints.get(laneKey); - const transitions = checkpoint - ? snapshot.transitions.filter(transition => transition.id > checkpoint.maxTransitionId) - : snapshot.transitions; - const comparisonSnapshot: IUnifiedDocumentSnapshot = { - ...snapshot, - initialContent: checkpoint?.content ?? snapshot.initialContent, - transitions, - pendingReload: snapshot.pendingReload && (!checkpoint || snapshot.pendingReload.id > checkpoint.maxTransitionId) - ? snapshot.pendingReload - : undefined, - }; - if (options.skipAgentHostTransitions && transitions.some(transition => transition.kind === 'agentHost')) { - this._checkpoints.set(laneKey, createCheckpoint(snapshot)); - return undefined; - } - - let candidate = await projectUnifiedDocumentTracker(comparisonSnapshot, computeDiff); - let filteredReference = reference; - if (options.sourceFilter) { - candidate = filterEditTrackerSnapshot(candidate, options.sourceFilter); - filteredReference = filterEditTrackerSnapshot(reference, options.sourceFilter); - } - const referenceDetails = snapshotEditSourceDetails(filteredReference, undefined, 30, options.detailsOrder); - const candidateDetails = snapshotEditSourceDetails(candidate, undefined, 30, options.detailsOrder); - const result: IUnifiedEditTrackerShadowComparisonResult = { - candidate, - trackerComparison: compareEditTrackerSnapshots(filteredReference, candidate), - referenceDetails, - candidateDetails, - detailsComparison: compareEditSourceDetailsSnapshots(referenceDetails, candidateDetails), - }; - this._checkpoints.set(laneKey, createCheckpoint(snapshot)); - return result; - } - - private _recordResult(result: IUnifiedDocumentRegistryResult): void { - this._lastResults.set(this._key(result.resource), result); - } - - private _transferCheckpoints(previousResource: URI, resource: URI): void { - const previousKeySuffix = `:${this._key(previousResource)}`; - for (const [key, checkpoint] of this._checkpoints) { - if (key.endsWith(previousKeySuffix)) { - const lane = key.substring(0, key.length - previousKeySuffix.length); - this._checkpoints.delete(key); - this._checkpoints.set(this._laneKey(lane, resource), checkpoint); - } - } - } - - private _transferAgentRetention(previousResource: URI, resource: URI): void { - const previousKey = this._key(previousResource); - if (this._retainedAgentResources.delete(previousKey)) { - this._retainedAgentResources.add(this._key(resource)); - } - } - - private _scheduleCleanup(resource: URI): void { - const resourceKey = this._key(resource); - const keySuffix = `:${resourceKey}`; - const comparisons = Array.from(this._comparisonQueues) - .filter(([key]) => key.endsWith(keySuffix)) - .map(([, comparison]) => comparison); - Promise.allSettled(comparisons).then(() => { - const snapshot = this.getSnapshot(resource); - if (snapshot?.model || this._retainedAgentResources.has(resourceKey)) { - return; - } - this._registry.delete(resource); - this._lastResults.delete(resourceKey); - for (const key of this._checkpoints.keys()) { - if (key.endsWith(keySuffix)) { - this._checkpoints.delete(key); - } - } - }); - } - - private _laneKey(lane: string, resource: URI): string { - return `${lane}:${this._key(resource)}`; - } - - private _key(resource: URI): string { - return this._options.getComparisonKey(this._options.canonicalize(resource)); - } -} - -function createCheckpoint(snapshot: IUnifiedDocumentSnapshot): IUnifiedEditTrackerShadowCheckpoint { - let maxTransitionId = snapshot.pendingReload?.id ?? 0; - for (const transition of snapshot.transitions) { - maxTransitionId = Math.max(maxTransitionId, transition.id); - } - return { content: snapshot.content, maxTransitionId }; -} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts index eb1ffe0bb33e95..e4396c6d6df2f8 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -7,84 +7,36 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentHostTrackedFile, isDirtyOpenTextModel } from '../../browser/telemetry/agentHostEditSourceTracking.js'; -import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; suite('Agent Host Edit Source Tracking', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('tracks AI edits separately from reconciliation edits', async () => { + test('flushes current content with the latest language', async () => { const disposables = new DisposableStore(); - let currentText = ''; - let uuid = 0; - const sentTelemetry: { data: IEditSourcesDetailsTelemetryData; forwardToGitHub: boolean }[] = []; + const resource = URI.file('C:\\repo\\file.ts'); + let currentText = 'alpha'; + const flushes: { resource: URI; content: string; languageId: string; trigger: string }[] = []; const trackedFile = disposables.add(new AgentHostTrackedFile( - URI.file('C:\\repo\\file.ts'), - '', + resource, async () => currentText, - async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), () => undefined, - () => `stats-${++uuid}`, - (data, forwardToGitHub) => sentTelemetry.push({ data, forwardToGitHub }), new NullLogService(), () => { }, + (flushResource, content, languageId, trigger) => flushes.push({ resource: flushResource, content, languageId, trigger }), )); - await trackedFile.applyEdit('', 'alpha\n', agentHostEditSource('copilotcli', 'session-1', 'turn-1'), 'typescript'); - await trackedFile.applyEdit('alpha\n', 'alpha\nbeta\n', agentHostEditSource('claude', 'session-1', 'turn-2'), 'typescript'); - currentText = 'alpha\nX\n'; - await trackedFile.flush('hashChange'); + await trackedFile.applyEdit('typescript'); await trackedFile.flush('hashChange'); + currentText = 'beta'; + await trackedFile.applyEdit('javascript'); + await trackedFile.flush('branchChange'); - assert.deepStrictEqual(sentTelemetry, [ - { - data: { - mode: 'longterm', - sourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', - sourceKeyCleaned: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', - extensionId: undefined, - extensionVersion: undefined, - modelId: undefined, - trigger: 'hashChange', - languageId: 'typescript', - statsUuid: 'stats-1', - conversationId: 'session-1', - requestId: 'turn-1', - origin: 'agentHost', - harness: 'copilotcli', - trackingScope: 'agentHostAIOnly', - modifiedCount: 6, - deltaModifiedCount: 6, - totalModifiedCount: 7, - }, - forwardToGitHub: true, - }, - { - data: { - mode: 'longterm', - sourceKey: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', - sourceKeyCleaned: 'source:Chat.applyEdits-$harness:claude-$origin:agentHost-$trackingScope:agentHostAIOnly', - extensionId: undefined, - extensionVersion: undefined, - modelId: undefined, - trigger: 'hashChange', - languageId: 'typescript', - statsUuid: 'stats-1', - conversationId: 'session-1', - requestId: 'turn-2', - origin: 'agentHost', - harness: 'claude', - trackingScope: 'agentHostAIOnly', - modifiedCount: 1, - deltaModifiedCount: 5, - totalModifiedCount: 7, - }, - forwardToGitHub: false, - }, + assert.deepStrictEqual(flushes, [ + { resource, content: 'alpha', languageId: 'typescript', trigger: 'hashChange' }, + { resource, content: 'beta', languageId: 'javascript', trigger: 'branchChange' }, ]); disposables.dispose(); @@ -105,57 +57,36 @@ suite('Agent Host Edit Source Tracking', () => { }); }); - test('provides a stable tracker snapshot at flush', async () => { + test('uses the transferred resource when flushing a rename', async () => { const disposables = new DisposableStore(); - let currentText = ''; - const snapshots: { content: string; sources: readonly { sourceKey: string; insertedCount: number; retainedCount: number }[] }[] = []; + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + const reads: URI[] = []; + const flushes: URI[] = []; const trackedFile = disposables.add(new AgentHostTrackedFile( - URI.file('C:\\repo\\file.ts'), - '', - async () => currentText, - async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + previousResource, + async currentResource => { + reads.push(currentResource); + return 'content'; + }, () => undefined, - () => 'stats-1', - () => { }, new NullLogService(), () => { }, - (_resource, content, snapshot) => snapshots.push({ - content, - sources: snapshot.sources.map(source => ({ - sourceKey: source.sourceKey, - insertedCount: source.insertedCount, - retainedCount: source.retainedCount, - })), - }), + currentResource => flushes.push(currentResource), )); - currentText = 'agent'; - await trackedFile.applyEdit('', currentText, agentHostEditSource('copilotcli', 'session-1', 'turn-1'), 'typescript'); + trackedFile.setResource(resource); await trackedFile.flush('hashChange'); - assert.deepStrictEqual(snapshots, [{ - content: 'agent', - sources: [{ - sourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', - insertedCount: 5, - retainedCount: 5, - }], - }]); + assert.deepStrictEqual({ + resource: trackedFile.resource, + reads, + flushes, + }, { + resource, + reads: [resource], + flushes: [resource], + }); disposables.dispose(); }); }); - -function agentHostEditSource(harness: string, sessionId: string, turnId: string) { - return EditSources.chatApplyEdits({ - modelId: undefined, - sessionId, - requestId: turnId, - languageId: 'typescript', - mode: undefined, - extensionId: undefined, - codeBlockSuggestionId: undefined, - harness, - origin: 'agentHost', - trackingScope: 'agentHostAIOnly', - }); -} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts index e8c53a1fafe25a..b6714ae0d1cb72 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts @@ -204,6 +204,6 @@ function agentSource(): TextModelEditSource { codeBlockSuggestionId: undefined, harness: 'copilotcli', origin: 'agentHost', - trackingScope: 'agentHostAIOnly', + trackingScope: 'unified', }); } diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts index 69eeeecfa2a78b..a48fad92e4cb73 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts @@ -115,7 +115,7 @@ suite('Unified Document Tracker Projection', () => { targetContent: 'after', hasPendingReload: false, sources: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', insertedCount: 5, retainedCount: 5, }], @@ -160,7 +160,7 @@ suite('Unified Document Tracker Projection', () => { }); const projected = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); - const filtered = filterEditTrackerSnapshot(projected, source => source.trackingScope === 'agentHostAIOnly'); + const filtered = filterEditTrackerSnapshot(projected, source => source.trackingScope === 'unified'); assert.deepStrictEqual({ sourceIndex: filtered.sources[0]?.sourceIndex, @@ -170,8 +170,8 @@ suite('Unified Document Tracker Projection', () => { details: { totalModifiedCount: 9, rows: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', - cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', extensionId: undefined, extensionVersion: undefined, modelId: 'model', @@ -179,7 +179,7 @@ suite('Unified Document Tracker Projection', () => { requestId: 'request', origin: 'agentHost', harness: 'copilotcli', - trackingScope: 'agentHostAIOnly', + trackingScope: 'unified', modifiedCount: 9, deltaModifiedCount: 9, }], @@ -224,7 +224,7 @@ function agentSource(): TextModelEditSource { codeBlockSuggestionId: undefined, harness: 'copilotcli', origin: 'agentHost', - trackingScope: 'agentHostAIOnly', + trackingScope: 'unified', }); } diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts new file mode 100644 index 00000000000000..49b70ab5e6bb26 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts @@ -0,0 +1,351 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { IRandomService } from '../../browser/randomService.js'; +import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; +import { UnifiedEditSourceTracking } from '../../browser/telemetry/unifiedEditSourceTracking.js'; + +suite('Unified Edit Source Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('emits mixed local and Agent Host details once per long-term window', async () => { + const context = createContext('base'); + const resource = context.document.uri; + context.tracking.applyAgentEdit({ + resource, + before: 'base', + after: 'baseA', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.document.apply(StringEditWithReason.replace(new OffsetRange(4, 4), 'A', EditSources.reloadFromDisk())); + context.setDirty(true); + context.document.apply(StringEditWithReason.replace(new OffsetRange(5, 5), 'U', EditSources.cursor({ kind: 'type' }))); + + const first = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + const second = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-2'); + + assert.deepStrictEqual({ + first, + second, + events: context.details.map(event => ({ + sourceKey: event.sourceKey, + trigger: event.trigger, + languageId: event.languageId, + statsUuid: event.statsUuid, + origin: event.origin, + harness: event.harness, + trackingScope: event.trackingScope, + modifiedCount: event.modifiedCount, + deltaModifiedCount: event.deltaModifiedCount, + totalModifiedCount: event.totalModifiedCount, + })), + }, { + first: { + totalModifiedCount: 2, + rows: [ + { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + extensionId: undefined, + extensionVersion: undefined, + modelId: 'model', + conversationId: 'session', + requestId: 'request', + origin: 'agentHost', + harness: 'copilotcli', + trackingScope: 'unified', + modifiedCount: 1, + deltaModifiedCount: 1, + }, + { + sourceKey: 'source:cursor-kind:type', + cleanedSourceKey: 'source:cursor-kind:type', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + conversationId: undefined, + requestId: undefined, + origin: undefined, + harness: undefined, + trackingScope: undefined, + modifiedCount: 1, + deltaModifiedCount: 1, + }, + ], + }, + second: undefined, + events: [ + { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + origin: 'agentHost', + harness: 'copilotcli', + trackingScope: 'unified', + modifiedCount: 1, + deltaModifiedCount: 1, + totalModifiedCount: 2, + }, + { + sourceKey: 'source:cursor-kind:type', + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + origin: undefined, + harness: undefined, + trackingScope: undefined, + modifiedCount: 1, + deltaModifiedCount: 1, + totalModifiedCount: 2, + }, + ], + }); + context.disposables.dispose(); + }); + + test('reconciles the current disk snapshot before emitting retained counts', async () => { + const context = createContext('base'); + const resource = context.document.uri; + context.tracking.applyAgentEdit({ + resource, + before: 'base', + after: 'baseABCDEFGHIJ', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.document.apply(StringEditWithReason.replace(new OffsetRange(4, 4), 'ABCDEFGHIJ', EditSources.reloadFromDisk())); + context.setDiskContent('baseABC'); + + await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + + const agentEvent = context.details.find(event => event.origin === 'agentHost'); + assert.deepStrictEqual(agentEvent && { + sourceKey: agentEvent.sourceKey, + modifiedCount: agentEvent.modifiedCount, + deltaModifiedCount: agentEvent.deltaModifiedCount, + totalModifiedCount: agentEvent.totalModifiedCount, + }, { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + modifiedCount: 7, + deltaModifiedCount: 14, + totalModifiedCount: 7, + }); + context.disposables.dispose(); + }); + + test('waits for a pending reload to be attributed before flushing', async () => { + const context = createContext('before'); + const resource = context.document.uri; + context.document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + + const pending = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript'); + context.tracking.applyAgentEdit({ + resource, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.setDiskContent('after'); + const attributed = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + + assert.deepStrictEqual({ + pending, + attributed: attributed?.rows.map(row => ({ + sourceKey: row.sourceKey, + modifiedCount: row.modifiedCount, + })), + eventCount: context.details.length, + }, { + pending: undefined, + attributed: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + modifiedCount: 5, + }], + eventCount: 1, + }); + context.disposables.dispose(); + }); + + test('emits only the top thirty retained sources in descending order', async () => { + const context = createContext(''); + context.setDirty(true); + for (let i = 1; i <= 31; i++) { + const content = 'x'.repeat(i); + context.document.apply(StringEditWithReason.replace( + OffsetRange.emptyAt(context.document.value.get().value.length), + content, + EditSources.unknown({ name: `source-${i}` }), + )); + } + + await context.tracking.flushLongTermDetails(context.document.uri, 'hashChange', 'typescript'); + + assert.deepStrictEqual({ + count: context.details.length, + first: context.details[0]?.sourceKey, + last: context.details.at(-1)?.sourceKey, + containsSmallest: context.details.some(event => event.sourceKey === 'source:unknown-name:source-1'), + }, { + count: 30, + first: 'source:unknown-name:source-31', + last: 'source:unknown-name:source-2', + containsSmallest: false, + }); + context.disposables.dispose(); + }); + + test('keeps a resource until local and Agent Host ownership end', async () => { + const context = createContext('content'); + const resource = context.document.uri; + context.tracking.retainLocalLongTermResource(resource); + context.tracking.retainAgentResource(resource); + context.workspace.setDocuments([]); + await Promise.resolve(); + const whileRetained = !!context.tracking.getSnapshot(resource); + + context.tracking.releaseLocalLongTermResource(resource); + context.tracking.releaseAgentResource(resource); + await Promise.resolve(); + + assert.deepStrictEqual({ + whileRetained, + afterRelease: context.tracking.getSnapshot(resource), + }, { + whileRetained: true, + afterRelease: undefined, + }); + context.disposables.dispose(); + }); +}); + +function createContext(initialContent: string) { + const disposables = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = disposables.add(new TestObservableDocument(initialContent)); + workspace.setDocuments([document]); + const details: IEditSourcesDetailsTelemetryData[] = []; + let dirty = false; + let diskContent = initialContent; + let uuid = 0; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stub(IFileService, { + hasProvider: () => true, + readFile: async resource => { + const value = VSBuffer.fromString(diskContent); + return { + resource, + name: 'file.ts', + size: value.byteLength, + mtime: 0, + ctime: 0, + etag: '', + readonly: false, + locked: false, + executable: false, + value, + }; + }, + }); + instantiationService.stub(ITextFileService, { isDirty: () => dirty }); + instantiationService.stub(IUriIdentityService, { extUri, asCanonicalUri: resource => resource }); + instantiationService.stub(IEditorWorkerService, { + computeStringEditFromDiff: (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + }); + instantiationService.stub(IRandomService, { + _serviceBrand: undefined, + generateUuid: () => `stats-${++uuid}`, + generatePrefixedUuid: namespace => `${namespace}-${++uuid}`, + }); + instantiationService.stub(ITelemetryService, { + publicLog2(eventName, data) { + if (eventName === 'editTelemetry.editSources.details') { + details.push(data as IEditSourcesDetailsTelemetryData); + } + }, + }); + instantiationService.stub(ILogService, new NullLogService()); + const tracking = disposables.add(instantiationService.createInstance(UnifiedEditSourceTracking, workspace)); + return { + disposables, + workspace, + document, + tracking, + details, + setDirty(value: boolean) { + dirty = value; + }, + setDiskContent(value: string) { + diskContent = value; + }, + }; +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + trackingScope: 'unified', + }); +} + +class TestWorkspace extends ObservableWorkspace { + private readonly _documents = observableValue(this, []); + override readonly documents: IObservable = this._documents; + + setDocuments(documents: readonly IObservableDocument[]): void { + this._documents.set(documents, undefined); + } +} + +class TestObservableDocument extends Disposable implements IObservableDocument { + private readonly _value: ISettableObservable; + readonly value: IObservableWithChange; + readonly version: IObservable; + readonly languageId: IObservable; + + constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + this.version = observableValue(this, 1); + this.languageId = observableValue(this, 'typescript'); + } + + apply(edit: StringEditWithReason): void { + this._value.set(edit.applyOnText(this._value.get()), undefined, edit); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts deleted file mode 100644 index 884ce2e27e0ca1..00000000000000 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditTrackerShadowTracking.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; -import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; -import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; -import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; -import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; -import { IEditTrackerSnapshot, projectUnifiedDocumentTracker } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; -import { UnifiedEditTrackerShadowTracking } from '../../browser/telemetry/unifiedEditTrackerShadowTracking.js'; - -suite('Unified Edit Tracker Shadow Tracking', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('mirrors model and Agent Host inputs without duplicating attribution', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const document = store.add(new TestObservableDocument('before')); - workspace.setDocuments([document]); - const shadow = store.add(createShadow(workspace, () => false)); - - const agentResult = shadow.applyAgentEdit({ - resource: document.uri, - before: 'before', - after: 'after', - source: agentSource(), - correlation: 'tool-1', - kind: 'edit', - }); - document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); - const candidate = await shadow.project(document.uri, computeDiff); - - assert.deepStrictEqual({ - agentOutcome: agentResult.transitionResult.outcome, - lastOutcome: shadow.getLastResult(document.uri)?.outcome, - content: candidate?.content, - hasPendingReload: candidate?.hasPendingReload, - sources: candidate?.sources.map(source => ({ - sourceKey: source.sourceKey, - insertedCount: source.insertedCount, - retainedCount: source.retainedCount, - })), - }, { - agentOutcome: 'applied', - lastOutcome: 'duplicate', - content: 'after', - hasPendingReload: false, - sources: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:agentHostAIOnly', - insertedCount: 5, - retainedCount: 5, - }], - }); - store.dispose(); - }); - - test('tracks dirty skips and flush-time disk snapshots', () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const document = store.add(new TestObservableDocument('dirty')); - workspace.setDocuments([document]); - const shadow = store.add(createShadow(workspace, () => true)); - - const agentResult = shadow.applyAgentEdit({ - resource: document.uri, - before: 'disk', - after: 'agent', - source: agentSource(), - correlation: 'tool-1', - kind: 'edit', - }); - const diskResult = shadow.applyDiskSnapshot(document.uri, 'agent'); - - assert.deepStrictEqual({ - agentOutcome: agentResult.transitionResult.outcome, - diskOutcome: diskResult.outcome, - snapshot: shadow.getSnapshot(document.uri), - }, { - agentOutcome: 'skippedDirty', - diskOutcome: 'conflict', - snapshot: { - initialContent: 'dirty', - content: 'dirty', - diskContent: 'agent', - model: { content: 'dirty', dirty: true }, - pendingReload: undefined, - transitions: [], - }, - }); - store.dispose(); - }); - - test('compares a projected candidate on demand', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const shadow = store.add(createShadow(workspace, () => false)); - const resource = URI.file('C:\\repo\\file.ts'); - shadow.applyAgentEdit({ - resource, - before: '', - after: 'agent', - source: agentSource(), - correlation: 'tool-1', - kind: 'create', - }); - const candidate = await shadow.project(resource, computeDiff); - const reference: IEditTrackerSnapshot = { - ...candidate!, - totalRetainedCount: 0, - }; - const result = await shadow.compare(resource, reference, computeDiff); - - assert.deepStrictEqual(result?.trackerComparison, { - equal: false, - differences: ['totalRetainedCount: expected 0, got 5'], - }); - store.dispose(); - }); - - test('checkpoints repeated Agent Host comparison windows', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const shadow = store.add(createShadow(workspace, () => false)); - const resource = URI.file('C:\\repo\\file.ts'); - shadow.applyAgentEdit({ - resource, - before: '', - after: 'one', - source: agentSource(), - correlation: 'tool-1', - kind: 'create', - }); - const firstReference = await projectSingleAgentTransition('', 'one', 'tool-1', 'create'); - const first = await shadow.compareAndCheckpoint( - 'agentHost', - resource, - firstReference, - computeDiff, - { sourceFilter: source => source.trackingScope === 'agentHostAIOnly' }, - ); - - shadow.applyAgentEdit({ - resource, - before: 'one', - after: 'two', - source: agentSource(), - correlation: 'tool-2', - kind: 'edit', - }); - const secondReference = await projectSingleAgentTransition('one', 'two', 'tool-2', 'edit'); - const second = await shadow.compareAndCheckpoint( - 'agentHost', - resource, - secondReference, - computeDiff, - { sourceFilter: source => source.trackingScope === 'agentHostAIOnly' }, - ); - - assert.deepStrictEqual({ - firstTracker: first?.trackerComparison, - firstDetails: first?.detailsComparison, - secondTracker: second?.trackerComparison, - secondDetails: second?.detailsComparison, - secondCandidateContent: second?.candidate.content, - secondInsertedCount: second?.candidate.sources[0]?.insertedCount, - }, { - firstTracker: { equal: true, differences: [] }, - firstDetails: { equal: true, differences: [] }, - secondTracker: { equal: true, differences: [] }, - secondDetails: { equal: true, differences: [] }, - secondCandidateContent: 'two', - secondInsertedCount: 3, - }); - store.dispose(); - }); - - test('checkpoints local windows containing Agent Host transitions without comparing them', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const document = store.add(new TestObservableDocument('before')); - workspace.setDocuments([document]); - const shadow = store.add(createShadow(workspace, () => false)); - shadow.startComparison('local', document.uri); - shadow.applyAgentEdit({ - resource: document.uri, - before: 'before', - after: 'after', - source: agentSource(), - correlation: 'tool-1', - kind: 'edit', - }); - document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); - const skipped = await shadow.compareAndCheckpoint( - 'local', - document.uri, - emptySnapshot('after'), - computeDiff, - { skipAgentHostTransitions: true }, - ); - - document.apply(StringEditWithReason.replace(new OffsetRange(5, 5), '!', EditSources.cursor({ kind: 'type' }))); - const userReference = await projectSingleModelTransition('after', 'after!'); - const compared = await shadow.compareAndCheckpoint( - 'local', - document.uri, - userReference, - computeDiff, - { skipAgentHostTransitions: true }, - ); - - assert.deepStrictEqual({ - skipped, - trackerComparison: compared?.trackerComparison, - detailsComparison: compared?.detailsComparison, - }, { - skipped: undefined, - trackerComparison: { equal: true, differences: [] }, - detailsComparison: { equal: true, differences: [] }, - }); - store.dispose(); - }); - - test('releases closed resources after queued comparisons complete', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const document = store.add(new TestObservableDocument('content')); - workspace.setDocuments([document]); - const shadow = store.add(createShadow(workspace, () => false)); - shadow.startComparison('local', document.uri); - const comparison = shadow.compareAndCheckpoint('local', document.uri, emptySnapshot('content'), computeDiff); - - workspace.setDocuments([]); - await comparison; - await Promise.resolve(); - - assert.strictEqual(shadow.getSnapshot(document.uri), undefined); - store.dispose(); - }); - - test('retains closed resources while an Agent Host tracker owns them', async () => { - const store = new DisposableStore(); - const workspace = new TestWorkspace(); - const shadow = store.add(createShadow(workspace, () => false)); - const resource = URI.file('C:\\repo\\file.ts'); - shadow.applyAgentEdit({ - resource, - before: '', - after: 'content', - source: agentSource(), - correlation: 'tool-1', - kind: 'create', - }); - shadow.retainAgentResource(resource); - const retained = !!shadow.getSnapshot(resource); - shadow.releaseAgentResource(resource); - await Promise.resolve(); - - assert.deepStrictEqual({ - retained, - afterRelease: shadow.getSnapshot(resource), - }, { - retained: true, - afterRelease: undefined, - }); - store.dispose(); - }); -}); - -function createShadow(workspace: ObservableWorkspace, isDirty: (resource: URI) => boolean): UnifiedEditTrackerShadowTracking { - return new UnifiedEditTrackerShadowTracking(workspace, { - isDirty, - canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), - getComparisonKey: resource => resource.toString().toLowerCase(), - }); -} - -function computeDiff(before: string, after: string) { - return computeStringDiff(before, after, { maxComputationTimeMs: 500 }, 'advanced'); -} - -function agentSource(): TextModelEditSource { - return EditSources.chatApplyEdits({ - modelId: 'model', - sessionId: 'session', - requestId: 'request', - languageId: 'typescript', - mode: 'agent', - extensionId: undefined, - codeBlockSuggestionId: undefined, - harness: 'copilotcli', - origin: 'agentHost', - trackingScope: 'agentHostAIOnly', - }); -} - -async function projectSingleAgentTransition( - before: string, - after: string, - correlation: string, - kind: 'create' | 'edit', -): Promise { - const reconciler = new UnifiedDocumentReconciler(before, EditSources.reloadFromDisk()); - reconciler.agentTransition({ before, after, source: agentSource(), correlation, kind }); - return projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); -} - -async function projectSingleModelTransition(before: string, after: string): Promise { - const reconciler = new UnifiedDocumentReconciler(before, EditSources.reloadFromDisk()); - reconciler.modelConnected({ content: before, dirty: false }); - reconciler.modelEdit({ - before, - after, - source: EditSources.cursor({ kind: 'type' }), - kind: 'model', - dirty: true, - }); - return projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); -} - -function emptySnapshot(content: string): IEditTrackerSnapshot { - return { - content, - targetContent: content, - hasPendingReload: false, - totalRetainedCount: 0, - sources: [], - ranges: [], - }; -} - -class TestWorkspace extends ObservableWorkspace { - private readonly _documents = observableValue(this, []); - override readonly documents: IObservable = this._documents; - - setDocuments(documents: readonly IObservableDocument[]): void { - this._documents.set(documents, undefined); - } -} - -class TestObservableDocument extends Disposable implements IObservableDocument { - private readonly _value: ISettableObservable; - readonly value: IObservableWithChange; - readonly version: IObservable; - readonly languageId: IObservable; - - constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { - super(); - this.value = this._value = observableValue(this, new StringText(initialContent)); - this.version = observableValue(this, 1); - this.languageId = observableValue(this, 'typescript'); - } - - apply(edit: StringEditWithReason): void { - this._value.set(edit.applyOnText(this._value.get()), undefined, edit); - } -} From a590cd7feb96d93cf0c6e8d3bdbee2c96f40cc74 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:04:46 -0700 Subject: [PATCH 21/21] Remove redundant edit tracking scope Agent Host edits now ship only through the canonical long-term tracker, so a tracking-scope field cannot distinguish production cohorts. Keep origin and harness as the meaningful Agent Host source dimensions and remove leftover comparison-only helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/editor/common/textModelEditSource.ts | 2 - .../telemetry/agentHostEditSourceTracking.ts | 2 - .../browser/telemetry/editSourceTelemetry.ts | 2 - .../telemetry/editSourceTrackingImpl.ts | 1 - .../unifiedDocumentTrackerProjection.ts | 78 +----------------- .../telemetry/unifiedEditSourceTracking.ts | 3 +- .../browser/unifiedDocumentAdapters.test.ts | 1 - .../unifiedDocumentTrackerProjection.test.ts | 81 +------------------ .../browser/unifiedEditSourceTracking.test.ts | 16 ++-- 9 files changed, 11 insertions(+), 175 deletions(-) diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index 3973745175c5c2..27b7304b7c1b1e 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -110,7 +110,6 @@ export const EditSources = { codeBlockSuggestionId: EditSuggestionId | undefined; harness?: string; origin?: string; - trackingScope?: string; }) { return createEditSource({ source: 'Chat.applyEdits', @@ -119,7 +118,6 @@ export const EditSources = { $extensionVersion: data.extensionId?.version, $harness: data.harness, $origin: data.origin, - $trackingScope: data.trackingScope, $$languageId: data.languageId, $$sessionId: data.sessionId, $$requestId: data.requestId, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts index 68f5e251ac6139..14a5d99f27a288 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -27,7 +27,6 @@ import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; -const UNIFIED_TRACKING_SCOPE = 'unified'; type GetRepo = (resource: URI, reader: IReader) => IScmRepoAdapter | undefined; @@ -224,7 +223,6 @@ export class AgentHostEditSourceTracking extends Disposable { codeBlockSuggestionId: undefined, harness, origin: 'agentHost', - trackingScope: UNIFIED_TRACKING_SCOPE, }); const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; this._unifiedTracking.applyAgentEdit({ diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts index d41e047d36284c..05c232371c0724 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts @@ -23,7 +23,6 @@ export interface IEditSourcesDetailsTelemetryData { requestId: string | undefined; origin: string | undefined; harness: string | undefined; - trackingScope: string | undefined; modifiedCount: number; deltaModifiedCount: number; totalModifiedCount: number; @@ -44,7 +43,6 @@ type EditSourcesDetailsTelemetryClassification = { requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; origin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The system that observed and attributed the edit.' }; harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent harness that produced the edit.' }; - trackingScope: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The set of edit sources represented by the row.' }; trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 3d537eea0d970a..0b1b7b401f023a 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -260,7 +260,6 @@ class TrackedDocumentInfo extends Disposable { requestId: repr.props.$$requestId, origin: repr.props.$origin, harness: repr.props.$harness, - trackingScope: repr.props.$trackingScope, modifiedCount: value, deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts index c500049791d89f..e9ef041531f6c7 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts @@ -27,7 +27,6 @@ export interface IEditTrackerSourceSnapshot { readonly requestId: string | undefined; readonly origin: string | undefined; readonly harness: string | undefined; - readonly trackingScope: string | undefined; readonly insertedCount: number; readonly retainedCount: number; } @@ -47,11 +46,6 @@ export interface IEditTrackerSnapshot { readonly ranges: readonly IEditTrackerRangeSnapshot[]; } -export interface IEditTrackerShadowComparison { - readonly equal: boolean; - readonly differences: readonly string[]; -} - export interface IEditSourceDetailsRowSnapshot { readonly sourceKey: string; readonly cleanedSourceKey: string; @@ -62,7 +56,6 @@ export interface IEditSourceDetailsRowSnapshot { readonly requestId: string | undefined; readonly origin: string | undefined; readonly harness: string | undefined; - readonly trackingScope: string | undefined; readonly modifiedCount: number; readonly deltaModifiedCount: number; } @@ -72,7 +65,6 @@ export interface IEditSourceDetailsSnapshot { readonly rows: readonly IEditSourceDetailsRowSnapshot[]; } -export type EditTrackerSourceSnapshotFilter = (source: IEditTrackerSourceSnapshot) => boolean; export type EditSourceDetailsOrder = 'tracker' | 'retained'; /** @@ -148,7 +140,6 @@ export function snapshotDocumentEditSourceTracker( requestId: representative?.props.$$requestId, origin: representative?.props.$origin, harness: representative?.props.$harness, - trackingScope: representative?.props.$trackingScope, insertedCount: tracker.getTotalInsertedCharactersCount(sourceKey), retainedCount: retainedByKey.get(sourceKey) ?? 0, }; @@ -164,40 +155,19 @@ export function snapshotDocumentEditSourceTracker( }; } -export function filterEditTrackerSnapshot( - snapshot: IEditTrackerSnapshot, - filter: EditTrackerSourceSnapshotFilter, -): IEditTrackerSnapshot { - const sources = snapshot.sources - .filter(filter) - .sort((left, right) => left.sourceIndex - right.sourceIndex) - .map((source, sourceIndex) => ({ ...source, sourceIndex })) - .sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); - const sourceKeys = new Set(sources.map(source => source.sourceKey)); - const ranges = snapshot.ranges.filter(range => sourceKeys.has(range.sourceKey)); - return { - ...snapshot, - totalRetainedCount: ranges.reduce((sum, range) => sum + range.endExclusive - range.start, 0), - sources, - ranges, - }; -} - export function snapshotEditSourceDetails( snapshot: IEditTrackerSnapshot, - filter: EditTrackerSourceSnapshotFilter = () => true, limit = 30, order: EditSourceDetailsOrder = 'tracker', ): IEditSourceDetailsSnapshot { - const filteredSources = snapshot.sources.filter(filter); const getOrder = (source: IEditTrackerSourceSnapshot) => order === 'retained' ? source.retainedIndex ?? snapshot.ranges.length + source.sourceIndex : source.sourceIndex; - const sources = filteredSources - .sort((left, right) => right.retainedCount - left.retainedCount || getOrder(left) - getOrder(right)) + const sources = snapshot.sources + .toSorted((left, right) => right.retainedCount - left.retainedCount || getOrder(left) - getOrder(right)) .slice(0, limit); return { - totalModifiedCount: filteredSources.reduce((sum, source) => sum + source.retainedCount, 0), + totalModifiedCount: snapshot.sources.reduce((sum, source) => sum + source.retainedCount, 0), rows: sources.map(source => ({ sourceKey: source.sourceKey, cleanedSourceKey: source.cleanedSourceKey, @@ -208,54 +178,12 @@ export function snapshotEditSourceDetails( requestId: source.requestId, origin: source.origin, harness: source.harness, - trackingScope: source.trackingScope, modifiedCount: source.retainedCount, deltaModifiedCount: source.insertedCount, })), }; } -export function compareEditTrackerSnapshots( - reference: IEditTrackerSnapshot, - candidate: IEditTrackerSnapshot, -): IEditTrackerShadowComparison { - const differences: string[] = []; - compareField('content', reference.content, candidate.content, differences); - compareField('targetContent', reference.targetContent, candidate.targetContent, differences); - compareField('hasPendingReload', reference.hasPendingReload, candidate.hasPendingReload, differences); - compareField('totalRetainedCount', reference.totalRetainedCount, candidate.totalRetainedCount, differences); - compareField('sources', reference.sources, candidate.sources, differences); - compareField('ranges', reference.ranges, candidate.ranges, differences); - return { equal: differences.length === 0, differences }; -} - -export function compareEditSourceDetailsSnapshots( - reference: IEditSourceDetailsSnapshot, - candidate: IEditSourceDetailsSnapshot, -): IEditTrackerShadowComparison { - const differences: string[] = []; - compareField('totalModifiedCount', reference.totalModifiedCount, candidate.totalModifiedCount, differences); - compareField( - 'rows', - reference.rows.toSorted((left, right) => left.sourceKey.localeCompare(right.sourceKey)), - candidate.rows.toSorted((left, right) => left.sourceKey.localeCompare(right.sourceKey)), - differences, - ); - return { equal: differences.length === 0, differences }; -} - -export function getEditTrackerComparisonDifferenceFields(comparison: IEditTrackerShadowComparison): readonly string[] { - return comparison.differences.map(difference => difference.substring(0, difference.indexOf(':'))); -} - -function compareField(field: string, reference: unknown, candidate: unknown, differences: string[]): void { - const referenceValue = JSON.stringify(reference); - const candidateValue = JSON.stringify(candidate); - if (referenceValue !== candidateValue) { - differences.push(`${field}: expected ${referenceValue}, got ${candidateValue}`); - } -} - class ProjectionDocument extends Disposable implements IDocumentWithAnnotatedEdits { private readonly _value: ISettableObservable }>; readonly value: IObservableWithChange }>; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts index da245935ada8bd..09d54171be2839 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts @@ -196,7 +196,7 @@ export class UnifiedEditSourceTracking extends Disposable { windowSnapshot, (before, after) => this._diffService.computeDiff(before, after), ); - const details = snapshotEditSourceDetails(trackerSnapshot, undefined, 30, 'retained'); + const details = snapshotEditSourceDetails(trackerSnapshot, 30, 'retained'); if (details.rows.length > 0) { for (const row of details.rows) { sendEditSourcesDetailsTelemetry(this._telemetryService, { @@ -213,7 +213,6 @@ export class UnifiedEditSourceTracking extends Disposable { requestId: row.requestId, origin: row.origin, harness: row.harness, - trackingScope: row.trackingScope, modifiedCount: row.modifiedCount, deltaModifiedCount: row.deltaModifiedCount, totalModifiedCount: details.totalModifiedCount, diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts index b6714ae0d1cb72..0adfeaf0796481 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts @@ -204,6 +204,5 @@ function agentSource(): TextModelEditSource { codeBlockSuggestionId: undefined, harness: 'copilotcli', origin: 'agentHost', - trackingScope: 'unified', }); } diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts index a48fad92e4cb73..5fad5e04b28f78 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts @@ -15,12 +15,9 @@ import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from ' import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; import { - compareEditTrackerSnapshots, - filterEditTrackerSnapshot, IEditTrackerSnapshot, projectUnifiedDocumentTracker, snapshotDocumentEditSourceTracker, - snapshotEditSourceDetails, } from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; suite('Unified Document Tracker Projection', () => { @@ -60,7 +57,7 @@ suite('Unified Document Tracker Projection', () => { { before: 'ab', after: 'abU', source: user }, ]); - assert.deepStrictEqual(compareEditTrackerSnapshots(reference, candidate), { equal: true, differences: [] }); + assert.deepStrictEqual(candidate, reference); }); test('projects reload-first attribution only after Agent Host claims it', async () => { @@ -115,7 +112,7 @@ suite('Unified Document Tracker Projection', () => { targetContent: 'after', hasPendingReload: false, sources: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', insertedCount: 5, retainedCount: 5, }], @@ -124,68 +121,6 @@ suite('Unified Document Tracker Projection', () => { ); }); - test('reports field-level shadow differences', () => { - const reference = emptySnapshot('reference'); - const candidate = { ...emptySnapshot('candidate'), totalRetainedCount: 1 }; - - assert.deepStrictEqual(compareEditTrackerSnapshots(reference, candidate), { - equal: false, - differences: [ - 'content: expected "reference", got "candidate"', - 'targetContent: expected "reference", got "candidate"', - 'totalRetainedCount: expected 0, got 1', - ], - }); - }); - - test('projects details payload fields for a filtered source scope', async () => { - const initialContent = ''; - const agent = agentSource(); - const reconciler = new UnifiedDocumentReconciler(initialContent, EditSources.reloadFromDisk()); - reconciler.modelConnected({ content: '', dirty: false }); - reconciler.modelEdit({ - before: '', - after: 'user', - source: EditSources.cursor({ kind: 'type' }), - kind: 'model', - dirty: true, - }); - reconciler.diskSnapshot('user'); - reconciler.agentTransition({ - before: 'user', - after: 'useragent', - source: agent, - correlation: 'tool-1', - kind: 'edit', - }); - - const projected = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); - const filtered = filterEditTrackerSnapshot(projected, source => source.trackingScope === 'unified'); - - assert.deepStrictEqual({ - sourceIndex: filtered.sources[0]?.sourceIndex, - details: snapshotEditSourceDetails(filtered), - }, { - sourceIndex: 0, - details: { - totalModifiedCount: 9, - rows: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', - cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', - extensionId: undefined, - extensionVersion: undefined, - modelId: 'model', - conversationId: 'session', - requestId: 'request', - origin: 'agentHost', - harness: 'copilotcli', - trackingScope: 'unified', - modifiedCount: 9, - deltaModifiedCount: 9, - }], - }, - }); - }); }); async function createReferenceSnapshot( @@ -224,21 +159,9 @@ function agentSource(): TextModelEditSource { codeBlockSuggestionId: undefined, harness: 'copilotcli', origin: 'agentHost', - trackingScope: 'unified', }); } -function emptySnapshot(content: string): IEditTrackerSnapshot { - return { - content, - targetContent: content, - hasPendingReload: false, - totalRetainedCount: 0, - sources: [], - ranges: [], - }; -} - class ReferenceDocument extends Disposable implements IDocumentWithAnnotatedEdits { private readonly _value: ISettableObservable }>; readonly value: IObservableWithChange }>; diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts index 49b70ab5e6bb26..75e160dcec91a9 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts @@ -58,7 +58,6 @@ suite('Unified Edit Source Tracking', () => { statsUuid: event.statsUuid, origin: event.origin, harness: event.harness, - trackingScope: event.trackingScope, modifiedCount: event.modifiedCount, deltaModifiedCount: event.deltaModifiedCount, totalModifiedCount: event.totalModifiedCount, @@ -68,8 +67,8 @@ suite('Unified Edit Source Tracking', () => { totalModifiedCount: 2, rows: [ { - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', - cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost', extensionId: undefined, extensionVersion: undefined, modelId: 'model', @@ -77,7 +76,6 @@ suite('Unified Edit Source Tracking', () => { requestId: 'request', origin: 'agentHost', harness: 'copilotcli', - trackingScope: 'unified', modifiedCount: 1, deltaModifiedCount: 1, }, @@ -91,7 +89,6 @@ suite('Unified Edit Source Tracking', () => { requestId: undefined, origin: undefined, harness: undefined, - trackingScope: undefined, modifiedCount: 1, deltaModifiedCount: 1, }, @@ -100,13 +97,12 @@ suite('Unified Edit Source Tracking', () => { second: undefined, events: [ { - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', trigger: 'hashChange', languageId: 'typescript', statsUuid: 'stats-1', origin: 'agentHost', harness: 'copilotcli', - trackingScope: 'unified', modifiedCount: 1, deltaModifiedCount: 1, totalModifiedCount: 2, @@ -118,7 +114,6 @@ suite('Unified Edit Source Tracking', () => { statsUuid: 'stats-1', origin: undefined, harness: undefined, - trackingScope: undefined, modifiedCount: 1, deltaModifiedCount: 1, totalModifiedCount: 2, @@ -151,7 +146,7 @@ suite('Unified Edit Source Tracking', () => { deltaModifiedCount: agentEvent.deltaModifiedCount, totalModifiedCount: agentEvent.totalModifiedCount, }, { - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', modifiedCount: 7, deltaModifiedCount: 14, totalModifiedCount: 7, @@ -186,7 +181,7 @@ suite('Unified Edit Source Tracking', () => { }, { pending: undefined, attributed: [{ - sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost-$trackingScope:unified', + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', modifiedCount: 5, }], eventCount: 1, @@ -319,7 +314,6 @@ function agentSource(): TextModelEditSource { codeBlockSuggestionId: undefined, harness: 'copilotcli', origin: 'agentHost', - trackingScope: 'unified', }); }