Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'; }
}
Expand Down Expand Up @@ -330,4 +330,3 @@ export function createDocWithJustReason(docWithAnnotatedEdits: IDocumentWithAnno
};
return docWithJustReason;
}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +22,7 @@ export class ArcTelemetryReporter extends Disposable {
private readonly _documentValueBeforeTrackedEdit: StringText,
private readonly _document: { value: IObservableWithChange<StringText, { edit: BaseStringEdit }> },
// _markedEdits -> document.value
private readonly _gitRepo: IObservable<ScmRepoAdapter | undefined>,
private readonly _gitRepo: IObservable<IScmRepoAdapter | undefined>,
private readonly _trackedEdit: BaseStringEdit,
private readonly _sendTelemetryEvent: (res: ArcTelemetryReporterData) => void,
private readonly _dispose: () => void,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +20,7 @@ import { IRandomService } from '../randomService.js';
export class EditTelemetryReportInlineEditArcSender extends Disposable {
constructor(
docWithAnnotatedEdits: IDocumentWithAnnotatedEdits<EditSourceData>,
scmRepoBridge: IObservable<ScmRepoAdapter | undefined>,
scmRepoBridge: IObservable<IScmRepoAdapter | undefined>,
@IInstantiationService private readonly _instantiationService: IInstantiationService
) {
super();
Expand Down Expand Up @@ -154,7 +154,7 @@ export class CreateSuggestionIdForChatOrInlineChatCaller extends Disposable {
export class EditTelemetryReportEditArcForChatOrInlineChatSender extends Disposable {
constructor(
docWithAnnotatedEdits: IDocumentWithAnnotatedEdits<EditSourceData>,
scmRepoBridge: IObservable<ScmRepoAdapter | undefined>,
scmRepoBridge: IObservable<IScmRepoAdapter | undefined>,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IRandomService private readonly _randomService: IRandomService,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,7 +64,7 @@ class TrackedDocumentInfo extends Disposable {
public readonly windowedTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
public readonly windowedFocusTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;

private readonly _repo: IObservable<ScmRepoAdapter | undefined>;
private readonly _repo: IObservable<IScmRepoAdapter | undefined>;

constructor(
private readonly _doc: AnnotatedDocument,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -30,7 +30,13 @@ export class ScmAdapter {
}
}

export class ScmRepoAdapter {
export interface IScmRepoAdapter {
readonly headBranchNameObs: IObservable<string | undefined>;
readonly headCommitHashObs: IObservable<string | undefined>;
isIgnored(uri: URI): Promise<boolean>;
}

export class ScmRepoAdapter implements IScmRepoAdapter {
public readonly headBranchNameObs: IObservable<string | undefined> = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.name);
public readonly headCommitHashObs: IObservable<string | undefined> = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.revision);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<EditSourceData>, 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<EditSourceData> {
private readonly _value: ISettableObservable<StringText, { edit: AnnotatedStringEdit<EditSourceData> }>;
readonly value: IObservableWithChange<StringText, { edit: AnnotatedStringEdit<EditSourceData> }>;

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<void> {
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,
});
}
Loading
Loading