diff --git a/extensions/git/package.json b/extensions/git/package.json index d3012834ba5..39017ca4e1e 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3614,6 +3614,22 @@ "default": false, "description": "%config.alwaysSignOff%" }, + "git.addAICoAuthor": { + "type": "string", + "enum": [ + "off", + "chatAndAgent", + "all" + ], + "enumDescriptions": [ + "%config.addAICoAuthor.off%", + "%config.addAICoAuthor.chatAndAgent%", + "%config.addAICoAuthor.all%" + ], + "scope": "resource", + "default": "off", + "description": "%config.addAICoAuthor%" + }, "git.ignoreSubmodules": { "type": "boolean", "scope": "resource", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 94a1f61a516..9d469e33c84 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -241,6 +241,10 @@ "config.worktreeIncludeFiles": "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) for files and folders that are included when creating a new worktree. Only files and folders that match the patterns and are listed in `.gitignore` will be copied to the newly created worktree.", "config.alwaysShowStagedChangesResourceGroup": "Always show the Staged Changes resource group.", "config.alwaysSignOff": "Controls the signoff flag for all commits.", + "config.addAICoAuthor": "Controls whether a 'Co-authored-by' trailer is automatically added to the commit message when AI-generated code is included in the commit.", + "config.addAICoAuthor.off": "Never add the AI co-author trailer.", + "config.addAICoAuthor.chatAndAgent": "Add the AI co-author trailer when code from chat or agent edits is included.", + "config.addAICoAuthor.all": "Add the AI co-author trailer when any AI-generated code is included, such as inline completions, chat, or agent edits.", "config.ignoreSubmodules": "Ignore modifications to submodules in the file tree.", "config.ignoredRepositories": "List of Git repositories to ignore.", "config.scanRepositories": "List of paths to search for Git repositories in.", diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index c6f121a01b3..8773264eb70 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -266,7 +266,7 @@ export class GitBlameController { arguments: ['git.blame'] }] satisfies Command[]); - return getCommitHover(commitAvatar, authorName, authorEmail, authorDate, message, commitInformation?.shortStat, commands); + return getCommitHover(commitAvatar, authorName, authorEmail, authorDate, message, commitInformation?.shortStat, commands, commitInformation?.coAuthors); } private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 2274087f032..5127ae0fbb9 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -748,6 +748,11 @@ export interface CommitShortStat { readonly deletions: number; } +export interface CoAuthor { + readonly name: string; + readonly email: string; +} + export interface Commit { hash: string; message: string; @@ -758,6 +763,7 @@ export interface Commit { commitDate?: Date; refNames: string[]; shortStat?: CommitShortStat; + coAuthors?: CoAuthor[]; } export interface RefQuery extends ApiRefQuery { @@ -951,13 +957,32 @@ export function parseGitCommits(data: string): Commit[] { authorEmail: ` ${authorEmail}`.substr(1), commitDate: new Date(Number(commitDate) * 1000), refNames: refNames.split(',').map(s => s.trim()), - shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined + shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined, + coAuthors: parseCoAuthors(message) }); } while (true); return commits; } +const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; + +export function parseCoAuthors(message: string): CoAuthor[] { + const coAuthors: CoAuthor[] = []; + let match; + + coAuthorRegex.lastIndex = 0; + while ((match = coAuthorRegex.exec(message)) !== null) { + const name = match[1].trim(); + const email = match[2].trim(); + if (name && email) { + coAuthors.push({ name, email }); + } + } + + return coAuthors; +} + const diffShortStatRegex = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/; function parseGitDiffShortStat(data: string): CommitShortStat { diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index f921f5734a5..29e8705e04b 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -309,7 +309,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec processHoverRemoteCommands(remoteHoverCommands, commit.hash) ]; - const tooltip = getHistoryItemHover(avatarUrl, commit.authorName, commit.authorEmail, commit.authorDate ?? commit.commitDate, messageWithLinks, commit.shortStat, commands); + const tooltip = getHistoryItemHover(avatarUrl, commit.authorName, commit.authorEmail, commit.authorDate ?? commit.commitDate, messageWithLinks, commit.shortStat, commands, commit.coAuthors); historyItems.push({ id: commit.hash, diff --git a/extensions/git/src/hover.ts b/extensions/git/src/hover.ts index 7d33893a348..62813e30785 100644 --- a/extensions/git/src/hover.ts +++ b/extensions/git/src/hover.ts @@ -6,18 +6,18 @@ import { Command, l10n, MarkdownString, Uri } from 'vscode'; import { fromNow, getCommitShortHash } from './util'; import { emojify } from './emoji'; -import { CommitShortStat } from './git'; +import { CoAuthor, CommitShortStat } from './git'; export const AVATAR_SIZE = 20; -export function getCommitHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined): MarkdownString { +export function getCommitHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined, coAuthors?: CoAuthor[]): MarkdownString { const markdownString = new MarkdownString('', true); markdownString.isTrusted = { enabledCommands: commands?.flat().map(c => c.command) ?? [] }; // Author, Subject | Message (escape image syntax) - appendContent(markdownString, authorAvatar, authorName, authorEmail, authorDate, message); + appendContent(markdownString, authorAvatar, authorName, authorEmail, authorDate, message, coAuthors); // Short stats if (shortStats) { @@ -32,12 +32,12 @@ export function getCommitHover(authorAvatar: string | undefined, authorName: str return markdownString; } -export function getHistoryItemHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined): MarkdownString[] { +export function getHistoryItemHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined, coAuthors?: CoAuthor[]): MarkdownString[] { const hoverContent: MarkdownString[] = []; // Author, Subject | Message (escape image syntax) const authorMarkdownString = new MarkdownString('', true); - appendContent(authorMarkdownString, authorAvatar, authorName, authorEmail, authorDate, message); + appendContent(authorMarkdownString, authorAvatar, authorName, authorEmail, authorDate, message, coAuthors); hoverContent.push(authorMarkdownString); // Short stats @@ -61,7 +61,7 @@ export function getHistoryItemHover(authorAvatar: string | undefined, authorName return hoverContent; } -function appendContent(markdownString: MarkdownString, authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string): void { +function appendContent(markdownString: MarkdownString, authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, coAuthors?: CoAuthor[]): void { // Author if (authorName) { // Avatar @@ -101,6 +101,26 @@ function appendContent(markdownString: MarkdownString, authorAvatar: string | un markdownString.appendMarkdown('\n\n'); } + // Co-authors + if (coAuthors && coAuthors.length > 0) { + for (const coAuthor of coAuthors) { + markdownString.appendMarkdown('$(account) '); + if (coAuthor.email) { + markdownString.appendMarkdown('[**'); + markdownString.appendText(coAuthor.name); + markdownString.appendMarkdown('**](mailto:'); + markdownString.appendText(coAuthor.email); + markdownString.appendMarkdown(')'); + } else { + markdownString.appendMarkdown('**'); + markdownString.appendText(coAuthor.name); + markdownString.appendMarkdown('**'); + } + markdownString.appendMarkdown(` _(${l10n.t('Co-author')})_`); + markdownString.appendMarkdown('\n\n'); + } + } + // Subject | Message (escape image syntax) markdownString.appendMarkdown(`${emojify(message.replace(/!\[/g, '![').replace(/\r\n|\r|\n/g, '\n\n'))}`); markdownString.appendMarkdown(`\n\n---\n\n`); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 24a6dca48c3..f3b1afb4689 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1304,6 +1304,12 @@ export class Repository implements Disposable { this.closeDiffEditors([...resources.length !== 0 ? resources.map(r => r.fsPath) : this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)], []); + + // Clear AI contribution tracking for reverted resources + const uris = resources.length !== 0 + ? resources + : this.indexGroup.resourceStates.map(r => r.resourceUri); + commands.executeCommand('_aiEdits.clearAiContributions', uris); }, () => { const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); @@ -1380,6 +1386,9 @@ export class Repository implements Disposable { opts.requireUserConfig = config.get('requireGitUserConfig'); } + // Add AI co-author trailer if applicable + message = await this.appendAICoAuthorTrailer(message, indexResources, workingGroupResources); + await this.repository.commit(message, opts); await this.commitOperationCleanup(message, indexResources, workingGroupResources); }, @@ -1403,6 +1412,55 @@ export class Repository implements Disposable { ? indexResources.map(r => Uri.file(r)) : workingGroupResources.map(r => Uri.file(r)); commands.executeCommand('_chat.editSessions.accept', resources); + + // Clear AI contribution tracking for committed resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); + } + + private static readonly AI_CO_AUTHOR_TRAILER = 'Co-authored-by: Copilot '; + + private async appendAICoAuthorTrailer( + message: string | undefined, + indexResources: string[], + workingGroupResources: string[] + ): Promise { + if (!message) { + return message; + } + + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'off'); + + if (addAICoAuthor === 'off') { + return message; + } + + // Don't add if trailer is already present + if (message.includes(Repository.AI_CO_AUTHOR_TRAILER)) { + return message; + } + + const resources = indexResources.length !== 0 + ? indexResources.map(r => Uri.file(r)) + : workingGroupResources.map(r => Uri.file(r)); + + if (resources.length === 0) { + return message; + } + + try { + const level = addAICoAuthor === 'all' ? 'all' : 'chatAndAgent'; + const hasAiContributions = await commands.executeCommand('_aiEdits.hasAiContributions', resources, level); + if (hasAiContributions) { + // Ensure proper trailer formatting: blank line before trailers + const trimmed = message.trimEnd(); + return `${trimmed}\n\n${Repository.AI_CO_AUTHOR_TRAILER}`; + } + } catch { + // Command may not be available (e.g., in web environment) + } + + return message; } private commitOperationGetOptimisticResourceGroups(opts: CommitOptions): GitResourceGroups { @@ -1505,6 +1563,9 @@ export class Repository implements Disposable { } this.closeDiffEditors([], [...toClean, ...toCheckout]); + + // Clear AI contribution tracking for discarded resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); }, () => { const resourcePaths = resources.map(r => r.fsPath); @@ -1980,12 +2041,20 @@ export class Repository implements Disposable { } await this.repository.checkout(treeish, [], opts); + + // Clear all AI contribution tracking on branch switch + commands.executeCommand('_aiEdits.clearAllAiContributions'); }); } async checkoutTracking(treeish: string, opts: { detached?: boolean } = {}): Promise { const refLabel = opts.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; - await this.run(Operation.CheckoutTracking(refLabel), () => this.repository.checkout(treeish, [], { ...opts, track: true })); + await this.run(Operation.CheckoutTracking(refLabel), async () => { + await this.repository.checkout(treeish, [], { ...opts, track: true }); + + // Clear all AI contribution tracking on branch switch + commands.executeCommand('_aiEdits.clearAllAiContributions'); + }); } async findTrackingBranches(upstreamRef: string): Promise { @@ -2014,7 +2083,14 @@ export class Repository implements Disposable { } async reset(treeish: string, hard?: boolean): Promise { - await this.run(Operation.Reset, () => this.repository.reset(treeish, hard)); + await this.run(Operation.Reset, async () => { + await this.repository.reset(treeish, hard); + + if (hard) { + // Clear all AI contribution tracking on hard reset + commands.executeCommand('_aiEdits.clearAllAiContributions'); + } + }); } async deleteRef(ref: string): Promise { diff --git a/extensions/git/src/test/git.test.ts b/extensions/git/src/test/git.test.ts index 129a3afcec4..b9c08fb907f 100644 --- a/extensions/git/src/test/git.test.ts +++ b/extensions/git/src/test/git.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'mocha'; -import { GitStatusParser, parseGitCommits, parseGitmodules, parseLsTree, parseLsFiles, parseGitRemotes } from '../git'; +import { GitStatusParser, parseGitCommits, parseGitmodules, parseLsTree, parseLsFiles, parseGitRemotes, parseCoAuthors } from '../git'; import * as assert from 'assert'; import { splitInChunks } from '../util'; @@ -289,7 +289,8 @@ suite('git', () => { authorEmail: 'john.doe@mail.com', commitDate: new Date(1580811031000), refNames: ['main', 'branch'], - shortStat: undefined + shortStat: undefined, + coAuthors: [] }]); }); @@ -313,7 +314,8 @@ suite('git', () => { authorEmail: 'john.doe@mail.com', commitDate: new Date(1580811031000), refNames: ['main'], - shortStat: undefined + shortStat: undefined, + coAuthors: [] }]); }); @@ -337,7 +339,8 @@ suite('git', () => { authorEmail: 'john.doe@mail.com', commitDate: new Date(1580811031000), refNames: ['main'], - shortStat: undefined + shortStat: undefined, + coAuthors: [] }]); }); @@ -366,7 +369,8 @@ suite('git', () => { deletions: 3, files: 1, insertions: 2 - } + }, + coAuthors: [] }]); }); @@ -395,7 +399,8 @@ suite('git', () => { deletions: 3, files: 1, insertions: 0 - } + }, + coAuthors: [] }]); }); @@ -424,7 +429,8 @@ suite('git', () => { deletions: 0, files: 1, insertions: 2 - } + }, + coAuthors: [] }]); }); @@ -458,6 +464,7 @@ suite('git', () => { commitDate: new Date(1580811031000), refNames: ['main', 'branch'], shortStat: undefined, + coAuthors: [] }, { hash: '52c293a05038d865604c2284aa8698bd087915a2', @@ -469,6 +476,7 @@ suite('git', () => { commitDate: new Date(1580811033000), refNames: ['main', 'branch'], shortStat: undefined, + coAuthors: [] }, ]); }); @@ -506,7 +514,8 @@ suite('git', () => { deletions: 13, files: 5, insertions: 12 - } + }, + coAuthors: [] }, { hash: '52c293a05038d865604c2284aa8698bd087915a2', @@ -521,7 +530,8 @@ suite('git', () => { deletions: 23, files: 6, insertions: 22 - } + }, + coAuthors: [] }]); }); }); @@ -590,6 +600,50 @@ suite('git', () => { }); }); + suite('parseCoAuthors', () => { + test('no co-authors', function () { + assert.deepStrictEqual(parseCoAuthors('This is a commit message.'), []); + }); + + test('single co-author', function () { + assert.deepStrictEqual( + parseCoAuthors('Fix bug\n\nCo-authored-by: Jane Doe '), + [{ name: 'Jane Doe', email: 'jane@example.com' }] + ); + }); + + test('multiple co-authors', function () { + assert.deepStrictEqual( + parseCoAuthors('Fix bug\n\nCo-authored-by: Jane Doe \nCo-authored-by: Bob Smith '), + [ + { name: 'Jane Doe', email: 'jane@example.com' }, + { name: 'Bob Smith', email: 'bob@example.com' } + ] + ); + }); + + test('case insensitive', function () { + assert.deepStrictEqual( + parseCoAuthors('Fix bug\n\nco-authored-by: Jane Doe '), + [{ name: 'Jane Doe', email: 'jane@example.com' }] + ); + }); + + test('AI co-author (Copilot)', function () { + assert.deepStrictEqual( + parseCoAuthors('Fix bug\n\nCo-authored-by: Copilot '), + [{ name: 'Copilot', email: 'copilot@github.com' }] + ); + }); + + test('mixed with other trailers', function () { + assert.deepStrictEqual( + parseCoAuthors('Fix bug\n\nSigned-off-by: Admin \nCo-authored-by: Jane Doe '), + [{ name: 'Jane Doe', email: 'jane@example.com' }] + ); + }); + }); + suite('splitInChunks', () => { test('unit tests', function () { assert.deepStrictEqual( diff --git a/src/vs/workbench/contrib/editTelemetry/browser/aiContributionFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/aiContributionFeature.ts new file mode 100644 index 00000000000..6dcef778f84 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/aiContributionFeature.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ResourceMap } from '../../../../base/common/map.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { AnnotatedDocument, IAnnotatedDocuments } from './helpers/annotatedDocuments.js'; +import { createDocWithJustReason } from './helpers/documentWithAnnotatedEdits.js'; +import { DocumentEditSourceTracker } from './telemetry/editTracker.js'; + +export type AiContributionLevel = 'chatAndAgent' | 'all'; + +interface TrackerEntry { + readonly trackerStore: DisposableStore; + readonly tracker: DocumentEditSourceTracker; +} + +/** + * Tracks AI-generated edits across open documents using the edit telemetry pipeline. + */ +export class AiContributionFeature extends Disposable { + + private readonly _trackers = new ResourceMap(); + private readonly _documentsByUri = new ResourceMap(); + + constructor( + annotatedDocuments: IAnnotatedDocuments, + ) { + super(); + + this._register(autorun(reader => { + const docs = annotatedDocuments.documents.read(reader); + const activeUris = new ResourceMap(); + + for (const doc of docs) { + const uri = doc.document.uri; + activeUris.set(uri, true); + this._documentsByUri.set(uri, doc); + + if (!this._trackers.has(uri)) { + this._trackers.set(uri, this._createTrackerEntry(doc)); + } + } + + for (const [uri, entry] of this._trackers) { + if (!activeUris.has(uri)) { + entry.trackerStore.dispose(); + this._trackers.delete(uri); + this._documentsByUri.delete(uri); + } + } + })); + + this._register(CommandsRegistry.registerCommand('_aiEdits.hasAiContributions', (_accessor, resources: UriComponents[], level: AiContributionLevel) => { + return this._hasAiContributions(resources, level); + })); + + this._register(CommandsRegistry.registerCommand('_aiEdits.clearAiContributions', (_accessor, resources: UriComponents[]) => { + this._clearAiContributions(resources); + })); + + this._register(CommandsRegistry.registerCommand('_aiEdits.clearAllAiContributions', () => { + this._clearAiContributions(); + })); + } + + override dispose(): void { + for (const [, entry] of this._trackers) { + entry.trackerStore.dispose(); + } + super.dispose(); + } + + private _createTrackerEntry(doc: AnnotatedDocument): TrackerEntry { + const trackerStore = new DisposableStore(); + const docWithJustReason = createDocWithJustReason(doc.documentWithAnnotations, trackerStore); + const tracker = trackerStore.add(new DocumentEditSourceTracker(docWithJustReason, undefined)); + return { trackerStore, tracker }; + } + + private _hasAiContributions(resources: UriComponents[], level: AiContributionLevel): boolean { + for (const resource of resources) { + const entry = this._trackers.get(URI.revive(resource)); + if (entry) { + for (const edit of entry.tracker.getTrackedRanges()) { + if (edit.source.category === 'ai' && (level === 'all' || edit.source.feature === 'chat')) { + return true; + } + } + } + } + return false; + } + + private _clearAiContributions(resources?: UriComponents[]): void { + const uris = resources ? resources.map(r => URI.revive(r)) : [...this._trackers.keys()]; + for (const uri of uris) { + const entry = this._trackers.get(uri); + if (entry) { + entry.trackerStore.dispose(); + const doc = this._documentsByUri.get(uri); + if (doc) { + this._trackers.set(uri, this._createTrackerEntry(doc)); + } else { + this._trackers.delete(uri); + this._documentsByUri.delete(uri); + } + } + } + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts index da642b8a11d..1c6db97c3be 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts @@ -15,6 +15,7 @@ import { VSCodeWorkspace } from './helpers/vscodeObservableWorkspace.js'; import { AiStatsFeature } from './editStats/aiStatsFeature.js'; import { AI_STATS_SETTING_ID, EDIT_TELEMETRY_SETTING_ID } from './settingIds.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { AiContributionFeature } from './aiContributionFeature.js'; export class EditTelemetryContribution extends Disposable { constructor( @@ -47,5 +48,13 @@ export class EditTelemetryContribution extends Disposable { r.store.add(instantiationService.createInstance(AiStatsFeature, annotatedDocuments.read(r))); })); + + const addAICoAuthor = observableConfigValue('git.addAICoAuthor', 'off', configurationService); + this._register(autorun(r => { + if (addAICoAuthor.read(r) === 'off') { + return; + } + r.store.add(instantiationService.createInstance(AiContributionFeature, annotatedDocuments.read(r))); + })); } } diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/aiContributionFeature.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/aiContributionFeature.test.ts new file mode 100644 index 00000000000..f6217cf4417 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/aiContributionFeature.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { AnnotatedDocuments, UriVisibilityProvider } from '../../browser/helpers/annotatedDocuments.js'; +import { StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { AiContributionFeature } from '../../browser/aiContributionFeature.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { DiffService } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { MutableObservableWorkspace } from './editTelemetry.test.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; + +suite('AiContributionFeature', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let workspace: MutableObservableWorkspace; + + const fileA = URI.parse('file:///a.ts'); + const fileB = URI.parse('file:///b.ts'); + + const chatEdit = EditSources.chatApplyEdits({ + languageId: 'plaintext', + modelId: undefined, + codeBlockSuggestionId: undefined, + extensionId: undefined, + mode: undefined, + requestId: undefined, + sessionId: undefined, + }); + + const userEdit = EditSources.cursor({ kind: 'type' }); + + const inlineCompletionEdit = EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'test-uuid', + languageId: 'plaintext', + correlationId: undefined, + }); + + function setup(): void { + 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') }); + instantiationService.stubInstance(UriVisibilityProvider, { isVisible: () => true }); + instantiationService.stub(ILogService, new NullLogService()); + + workspace = new MutableObservableWorkspace(); + const annotatedDocuments = disposables.add(new AnnotatedDocuments(workspace, instantiationService)); + disposables.add(instantiationService.createInstance(AiContributionFeature, annotatedDocuments)); + } + + function hasAiContributions(uris: URI[], level: 'chatAndAgent' | 'all'): boolean { + return CommandsRegistry.getCommand('_aiEdits.hasAiContributions')!.handler(undefined!, uris, level) as unknown as boolean; + } + + function clearAiContributions(uris: URI[]): void { + CommandsRegistry.getCommand('_aiEdits.clearAiContributions')!.handler(undefined!, uris); + } + + function clearAllAiContributions(): void { + CommandsRegistry.getCommand('_aiEdits.clearAllAiContributions')!.handler(undefined!); + } + + test('no contributions initially', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + assert.strictEqual(hasAiContributions([d.uri], 'all'), false); + assert.strictEqual(hasAiContributions([d.uri], 'chatAndAgent'), false); + disposables.dispose(); + })); + + test('detects chat AI edits', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + + d.applyEdit(StringEditWithReason.replace(d.findRange('hello'), 'world', chatEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([d.uri], 'all'), true); + assert.strictEqual(hasAiContributions([d.uri], 'chatAndAgent'), true); + disposables.dispose(); + })); + + test('detects inline completion AI edits at all level only', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + + d.applyEdit(StringEditWithReason.replace(d.findRange('hello'), 'world', inlineCompletionEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([d.uri], 'all'), true); + assert.strictEqual(hasAiContributions([d.uri], 'chatAndAgent'), false); + disposables.dispose(); + })); + + test('does not detect user edits as AI', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + + d.applyEdit(StringEditWithReason.replace(d.findRange('hello'), 'world', userEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([d.uri], 'all'), false); + assert.strictEqual(hasAiContributions([d.uri], 'chatAndAgent'), false); + disposables.dispose(); + })); + + test('clear resets contributions for specific resources', () => runWithFakedTimers({}, async () => { + setup(); + const dA = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + const dB = disposables.add(workspace.createDocument({ uri: fileB, initialValue: 'world' }, undefined)); + await timeout(1500); + + dA.applyEdit(StringEditWithReason.replace(dA.findRange('hello'), 'foo', chatEdit)); + dB.applyEdit(StringEditWithReason.replace(dB.findRange('world'), 'bar', chatEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([dA.uri], 'all'), true); + assert.strictEqual(hasAiContributions([dB.uri], 'all'), true); + + clearAiContributions([dA.uri]); + + assert.strictEqual(hasAiContributions([dA.uri], 'all'), false); + assert.strictEqual(hasAiContributions([dB.uri], 'all'), true); + disposables.dispose(); + })); + + test('clearAll resets all contributions', () => runWithFakedTimers({}, async () => { + setup(); + const dA = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + const dB = disposables.add(workspace.createDocument({ uri: fileB, initialValue: 'world' }, undefined)); + await timeout(1500); + + dA.applyEdit(StringEditWithReason.replace(dA.findRange('hello'), 'foo', chatEdit)); + dB.applyEdit(StringEditWithReason.replace(dB.findRange('world'), 'bar', chatEdit)); + await timeout(1500); + + clearAllAiContributions(); + + assert.strictEqual(hasAiContributions([dA.uri], 'all'), false); + assert.strictEqual(hasAiContributions([dB.uri], 'all'), false); + disposables.dispose(); + })); + + test('tracks new edits after clear', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + + d.applyEdit(StringEditWithReason.replace(d.findRange('hello'), 'world', chatEdit)); + await timeout(1500); + + clearAiContributions([d.uri]); + assert.strictEqual(hasAiContributions([d.uri], 'all'), false); + + d.applyEdit(StringEditWithReason.replace(d.findRange('world'), 'again', chatEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([d.uri], 'all'), true); + disposables.dispose(); + })); + + test('cleans up tracker when document is closed', () => runWithFakedTimers({}, async () => { + setup(); + const d = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + await timeout(1500); + + d.applyEdit(StringEditWithReason.replace(d.findRange('hello'), 'world', chatEdit)); + await timeout(1500); + + assert.strictEqual(hasAiContributions([d.uri], 'all'), true); + + d.dispose(); + await timeout(1500); + + assert.strictEqual(hasAiContributions([fileA], 'all'), false); + disposables.dispose(); + })); + + test('returns false for unknown URIs', () => runWithFakedTimers({}, async () => { + setup(); + assert.strictEqual(hasAiContributions([URI.parse('file:///unknown.ts')], 'all'), false); + disposables.dispose(); + })); + + test('checks multiple resources', () => runWithFakedTimers({}, async () => { + setup(); + const dA = disposables.add(workspace.createDocument({ uri: fileA, initialValue: 'hello' }, undefined)); + disposables.add(workspace.createDocument({ uri: fileB, initialValue: 'world' }, undefined)); + await timeout(1500); + + dA.applyEdit(StringEditWithReason.replace(dA.findRange('hello'), 'foo', chatEdit)); + await timeout(1500); + + // Returns true if any of the resources has AI contributions + assert.strictEqual(hasAiContributions([fileA, fileB], 'all'), true); + assert.strictEqual(hasAiContributions([fileB, fileA], 'all'), true); + assert.strictEqual(hasAiContributions([fileB], 'all'), false); + disposables.dispose(); + })); +});