diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 42deaf12f05f9..5dd2f87ae2c87 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -37,7 +37,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -382,6 +382,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } this._updateCodexEnabled(); } + if (e.affectsConfiguration(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateDisableRepoInfoTelemetry(); + } })); // Detect silently-dead transports — see {@link _resetLivenessTimers}. @@ -477,6 +483,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._updateSystemProxyEnabled(); this._updateTerminalAutoApproveRules(); this._updateCodexEnabled(); + this._updateDisableRepoInfoTelemetry(); this._transitionTo({ kind: AgentHostClientState.Connected }); } @@ -1351,6 +1358,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, this._clientId, 0); } + private _updateDisableRepoInfoTelemetry(): void { + const disabled = this._configurationService.getValue(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostDisableRepoInfoTelemetryConfigKey]: disabled }, + }, this._clientId, 0); + } + private _updateSessionSyncEnabled(): void { const enabled = !!this._configurationService.getValue(SESSION_SYNC_ENABLED_SETTING_ID); this.dispatchAction(ROOT_STATE_URI, { diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 329d03016995a..1030d018592be 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -52,6 +52,20 @@ export interface IComputeSessionFileDiffsOptions { readonly baseBranch?: string; } +/** Cheap repository facts used to decide whether a branch diff is safe to compute. */ +export interface IBranchDiffSafetyInfo { + readonly hasVirtualFileSystem: boolean; + readonly baselineCommitTimestamp: number | undefined; + readonly commitCount: number | undefined; + readonly workspaceFileCount: number; +} + +/** A bounded unified-diff result. */ +export interface IDiffPatchResult { + readonly patch: string | undefined; + readonly tooLarge: boolean; +} + /** Options for {@link IAgentHostGitService.push}. */ export interface IPushOptions { /** The branch or refspec to push. Defaults to the current branch. */ @@ -156,6 +170,10 @@ export interface IAgentHostGitService { * so the UI always reflects current branch/remote/change state. */ getSessionGitState(workingDirectory: URI): Promise; + /** Returns fetch remote URLs with the preferred remote, then `origin`, first. */ + getFetchRemoteUrls(workingDirectory: URI, preferredRemote?: string): Promise; + /** Returns repo-relative untracked file paths. */ + getUntrackedPaths(workingDirectory: URI): Promise; /** * Computes per-file diffs for the session by shelling out to `git @@ -264,6 +282,10 @@ export interface IAgentHostGitService { * terminal-tool edits the FileEditTracker pipeline misses. */ computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise; + /** Reads bounded facts needed before computing an expensive branch diff. */ + getBranchDiffSafetyInfo(workingDirectory: URI, baselineCommit: string): Promise; + /** Computes a unified patch for paths between immutable tree-ish values. */ + getDiffPatchBetweenRefs(workingDirectory: URI, options: { readonly fromRef: string; readonly toRef: string; readonly paths: readonly string[]; readonly maxBuffer: number }): Promise; } function getCommonBranchPriority(branch: string): number { diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 7612ddae9bc74..7fa90ff42847c 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -387,6 +387,12 @@ export function migrateLegacyAutopilotConfig | */ export const AgentHostTelemetryLevelConfigKey = 'telemetryLevel'; +/** Legacy Copilot Chat debug switch that disables `request.repoInfo` collection. */ +export const AgentHostDisableRepoInfoTelemetryConfigKey = 'disableRepoInfoTelemetry'; + +/** VS Code setting forwarded into {@link AgentHostDisableRepoInfoTelemetryConfigKey}. */ +export const DISABLE_REPO_INFO_TELEMETRY_SETTING_ID = 'chat.advanced.debug.disableRepoInfoTelemetry'; + /** * Root config key forwarded from the renderer when VS Code's * `chat.sessionSync.enabled` setting changes. Controls the `remote` flag @@ -655,6 +661,12 @@ const mcpServersValueProperties: Record = { export const platformRootSchema = createSchema({ [SessionConfigKey.Permissions]: permissionsProperty, + [AgentHostDisableRepoInfoTelemetryConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.disableRepoInfoTelemetry.title', "Disable Repository Information Telemetry"), + description: localize('agentHost.config.disableRepoInfoTelemetry.description', "Whether repository information telemetry is disabled for Agent Host sessions."), + default: false, + }), [AgentHostTelemetryLevelConfigKey]: schemaProperty({ type: 'string', title: localize('agentHost.config.telemetryLevel.title', "Telemetry Level"), diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 2a226c09cd448..319a1124f2e18 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -36,7 +36,7 @@ import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; /** * Renderer-side implementation of {@link IAgentHostService} that connects @@ -161,6 +161,9 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (e.affectsConfiguration(AgentHostCodexAgentEnabledSettingId)) { this._updateCodexEnabled(); } + if (e.affectsConfiguration(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID)) { + this._updateDisableRepoInfoTelemetry(); + } })); if (agentHostEnablementService.enabled) { @@ -206,6 +209,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos this._updateSystemProxyEnabled(); this._updateTerminalAutoApproveRules(); this._updateCodexEnabled(); + this._updateDisableRepoInfoTelemetry(); store.add(this._proxy.onDidAction(e => { const revived = revive(e) as ActionEnvelope; @@ -245,6 +249,14 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos }, this.clientId, 0); } + private _updateDisableRepoInfoTelemetry(): void { + const disabled = this._configurationService.getValue(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostDisableRepoInfoTelemetryConfigKey]: disabled }, + }, this.clientId, 0); + } + private _updateSessionSyncEnabled(): void { const enabled = !!this._configurationService.getValue(SESSION_SYNC_ENABLED_SETTING_ID); this.dispatchAction(ROOT_STATE_URI, { diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 834426710b52b..d7dfe97c58465 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -16,7 +16,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions, IAgentHostGitService, IComputeSessionFileDiffsOptions, IPullOptions, IPushOptions } from '../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, getBranchCompletions, IAgentHostGitService, IBranchDiffSafetyInfo, IComputeSessionFileDiffsOptions, IPullOptions, IPushOptions } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { Limiter, SequencerByKey } from '../../../base/common/async.js'; @@ -482,6 +482,23 @@ export class AgentHostGitService implements IAgentHostGitService { return this._computeSessionGitState(workingDirectory); } + async getFetchRemoteUrls(workingDirectory: URI, preferredRemote?: string): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + return parseFetchRemoteUrls(await this._runGit(repositoryRoot, ['remote', '-v']), preferredRemote); + } + + async getUntrackedPaths(workingDirectory: URI): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + const status = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']); + return status === undefined ? undefined : parseUntrackedPaths(status); + } + async captureWorkingTreeAsTree(workingDirectory: URI): Promise { const repositoryRoot = await this.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { @@ -620,6 +637,50 @@ export class AgentHostGitService implements IAgentHostGitService { } } + async getBranchDiffSafetyInfo(workingDirectory: URI, baselineCommit: string): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + + const [virtualFileSystem, sparseCheckout, timestamp, commitCount, workspaceFiles] = await Promise.all([ + this._runGit(repositoryRoot, ['config', '--get', 'core.virtualfilesystem']), + this._runGit(repositoryRoot, ['config', '--get', 'core.sparsecheckout']), + this._runGit(repositoryRoot, ['show', '-s', '--format=%ct', baselineCommit]), + this._runGit(repositoryRoot, ['rev-list', '--count', `${baselineCommit}..HEAD`]), + this._runGit(repositoryRoot, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']), + ]); + const sparseCheckoutEnabled = new Set(['true', 'yes', 'on', '1']).has(sparseCheckout?.trim().toLowerCase() ?? ''); + const timestampSeconds = Number(timestamp?.trim()); + const parsedCommitCount = Number(commitCount?.trim()); + return { + hasVirtualFileSystem: Boolean(virtualFileSystem?.trim()) || sparseCheckoutEnabled, + baselineCommitTimestamp: Number.isFinite(timestampSeconds) ? timestampSeconds * 1000 : undefined, + commitCount: Number.isFinite(parsedCommitCount) ? parsedCommitCount : undefined, + workspaceFileCount: workspaceFiles?.split('\x00').filter(Boolean).length ?? 0, + }; + } + + async getDiffPatchBetweenRefs(workingDirectory: URI, options: { readonly fromRef: string; readonly toRef: string; readonly paths: readonly string[]; readonly maxBuffer: number }): Promise<{ readonly patch: string | undefined; readonly tooLarge: boolean } | undefined> { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + const paths = [...new Set(options.paths)]; + if (paths.length === 0) { + return { patch: '', tooLarge: false }; + } + try { + const patch = await this._runGit(repositoryRoot, ['diff', '--patch', '--no-ext-diff', '--find-renames', '--diff-filter=ADMR', options.fromRef, options.toRef, '--', ...paths], { maxBuffer: options.maxBuffer, throwOnError: true }); + return patch === undefined ? undefined : { patch, tooLarge: false }; + } catch (error) { + if (isMaxBufferError(error)) { + return { patch: undefined, tooLarge: true }; + } + throw error; + } + } + private async _computeSessionGitState(workingDirectory: URI): Promise { const repositoryRoot = await this.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { @@ -1044,6 +1105,27 @@ export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean return /github\.com[:\/]/i.test(remotesOutput); } +/** Returns fetch remote URLs with the preferred remote, then `origin`, first. */ +export function parseFetchRemoteUrls(remotesOutput: string | undefined, preferredRemote?: string): string[] | undefined { + if (remotesOutput === undefined) { + return undefined; + } + const candidates: { name: string; url: string }[] = []; + for (const rawLine of remotesOutput.split(/\r?\n/)) { + const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(rawLine.trim()); + if (match) { + candidates.push({ name: match[1], url: match[2] }); + } + } + const preferredNames = new Set([preferredRemote, 'origin'].filter((name): name is string => Boolean(name))); + const ordered = [ + ...candidates.filter(candidate => candidate.name === preferredRemote), + ...candidates.filter(candidate => candidate.name === 'origin' && candidate.name !== preferredRemote), + ...candidates.filter(candidate => !preferredNames.has(candidate.name)), + ]; + return [...new Set(ordered.map(candidate => candidate.url))]; +} + /** * Parse `owner` and `repo` from `git remote -v` output. Prefers the `origin` * remote; falls back to the first GitHub remote so worktrees that renamed @@ -1053,28 +1135,11 @@ export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean * Exported for tests. */ export function parseGitHubRepoFromRemote(remotesOutput: string | undefined): { owner: string; repo: string } | undefined { - if (!remotesOutput) { + const candidates = parseFetchRemoteUrls(remotesOutput); + if (!candidates) { return undefined; } - // Each line: `\t ()`. Take fetch URLs only so we - // don't double-count the same remote. - const candidates: { name: string; url: string }[] = []; - for (const rawLine of remotesOutput.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) { continue; } - const m = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line); - if (!m) { continue; } - candidates.push({ name: m[1], url: m[2] }); - } - if (candidates.length === 0) { - return undefined; - } - // Prefer `origin`, otherwise first matching remote. - const ordered = [ - ...candidates.filter(c => c.name === 'origin'), - ...candidates.filter(c => c.name !== 'origin'), - ]; - for (const { url } of ordered) { + for (const url of candidates) { const parsed = parseGitHubOwnerRepoFromUrl(url); if (parsed) { return parsed; @@ -1117,3 +1182,8 @@ function stripUndefined(obj: T): T { } return out as T; } + +function isMaxBufferError(error: unknown): boolean { + const cause = error instanceof Error ? error.cause : undefined; + return cause instanceof Error && (cause as cp.ExecFileException).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; +} diff --git a/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts new file mode 100644 index 0000000000000..cf70d4b522865 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Limiter } from '../../../base/common/async.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { relativePath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import type { ISessionFileDiff } from '../common/state/sessionState.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import type { AgentHostRepoInfoResult, AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; +import type { IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js'; + +const MAX_DIFFS_JSON_BYTES = 900 * 1024; +const MAX_DIFFS_JSON_CHARS = 50 * 8192; +const MAX_CHANGES = 100; +const MAX_MERGE_BASE_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_DIFF_COMMITS = 30; +const DIFF_PATCH_CONCURRENCY = 4; +const MAX_DIFF_SIZE = 100_000; + +interface IRepoInfoContext extends IResolvedRepoInfoRemote { + readonly headCommitHash: string; + readonly headBranchName: string | undefined; +} + +interface IRepoInfoFileDescriptor { + readonly uri: string; + readonly originalUri: string; + readonly renameUri: string | undefined; + readonly status: 'INDEX_ADDED' | 'MODIFIED' | 'DELETED' | 'INDEX_RENAMED' | 'UNTRACKED'; + readonly oldPath: string | undefined; + readonly newPath: string | undefined; +} + +type RepoInfoTelemetryReporter = Pick; + +export interface IResolvedRepoInfoRemote { + readonly remoteUrl: string; + readonly repoId: string; + readonly repoType: 'github' | 'ado'; +} + +/** Resolves a GitHub, GitHub Enterprise, or Azure DevOps fetch URL. */ +export function resolveRepoInfoRemote(remoteUrl: string, enterpriseHost: string | undefined): IResolvedRepoInfoRemote | undefined { + const scpMatch = remoteUrl.includes('://') ? undefined : /^(?:[^@\s]+@)?(?[^:\s]+):(?.+)$/.exec(remoteUrl); + let host: string; + let path: string; + let normalizedRemoteUrl: string; + if (scpMatch?.groups) { + host = scpMatch.groups['host']; + path = scpMatch.groups['path']; + normalizedRemoteUrl = `https://${host}/${path}`; + } else { + let parsed: URL; + try { + parsed = new URL(remoteUrl); + } catch { + return undefined; + } + host = parsed.host; + path = parsed.pathname; + normalizedRemoteUrl = `https://${host}${path}`; + } + + const normalizedHost = host.toLowerCase(); + const normalizedHostname = normalizedHost.replace(/:\d+$/, ''); + const normalizedPath = path.replace(/^\/+|\/+$/g, ''); + if (normalizedHostname === 'github.com' || normalizedHost === enterpriseHost?.toLowerCase() || normalizedHostname === 'ghe.com' || normalizedHostname.endsWith('.ghe.com')) { + const match = /^(?[^/]+)\/(?[^/]+?)(?:\.git)?$/i.exec(normalizedPath); + if (!match?.groups) { + return undefined; + } + return { + remoteUrl: normalizedRemoteUrl, + repoId: `${match.groups['owner']}/${match.groups['repo']}`.toLowerCase(), + repoType: 'github', + }; + } + + let adoMatch: RegExpExecArray | null = null; + if (normalizedHostname === 'dev.azure.com') { + adoMatch = /^(?[^/]+)\/(?[^/]+)\/_git\/(?:_(?:optimized|full)\/)?(?[^/]+?)(?:\.git)?$/i.exec(normalizedPath); + } else if (normalizedHostname === 'ssh.dev.azure.com') { + adoMatch = /^v3\/(?[^/]+)\/(?[^/]+)\/(?:_(?:optimized|full)\/)?(?[^/]+?)(?:\.git)?$/i.exec(normalizedPath); + } else if (normalizedHostname.endsWith('.visualstudio.com')) { + adoMatch = /^v3\/(?[^/]+)\/(?[^/]+)\/(?:_(?:optimized|full)\/)?(?[^/]+?)(?:\.git)?$/i.exec(normalizedPath) + ?? /^(?:[^/]+\/)?(?[^/]+)\/_git\/(?:_(?:optimized|full)\/)?(?[^/]+?)(?:\.git)?$/i.exec(normalizedPath); + if (adoMatch?.groups && !adoMatch.groups['org']) { + adoMatch.groups['org'] = normalizedHostname.substring(0, normalizedHostname.length - '.visualstudio.com'.length); + } + } + if (!adoMatch?.groups?.['org'] || !adoMatch.groups['project'] || !adoMatch.groups['repo']) { + return undefined; + } + return { + remoteUrl: normalizedRemoteUrl, + repoId: `${adoMatch.groups['org']}/${adoMatch.groups['project']}/${adoMatch.groups['repo']}`.toLowerCase(), + repoType: 'ado', + }; +} + +/** Measures a serialized diff payload using the two limits applied by the legacy extension. */ +export function measureRepoInfoDiffsJSON(diffsJSON: string): { readonly diffSizeBytes: number; readonly tooLarge: boolean } { + const diffSizeBytes = Buffer.byteLength(diffsJSON, 'utf8'); + return { + diffSizeBytes, + tooLarge: diffSizeBytes > MAX_DIFFS_JSON_BYTES || diffsJSON.length > MAX_DIFFS_JSON_CHARS, + }; +} + +export class AgentHostRepoInfoTelemetry extends Disposable { + private readonly _beginResults = new Map>(); + private _isDisposed = false; + + constructor( + private readonly _reporter: RepoInfoTelemetryReporter, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async reportBegin(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise { + let result = this._beginResults.get(telemetryMessageId); + if (!result) { + result = this._captureSafely(context, sessionUri, telemetryMessageId, 'begin', workingDirectory, baseBranch, isContextCurrent); + this._beginResults.set(telemetryMessageId, result); + } + await result; + } + + async reportEnd(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise { + const begin = this._beginResults.get(telemetryMessageId); + if (!begin) { + return; + } + try { + const beginResult = await begin; + if (beginResult === 'success' || beginResult === 'noChanges') { + await this._captureSafely(context, sessionUri, telemetryMessageId, 'end', workingDirectory, baseBranch, isContextCurrent); + } + } finally { + this._beginResults.delete(telemetryMessageId); + } + } + + clearTurn(telemetryMessageId: string): void { + this._beginResults.delete(telemetryMessageId); + } + + override dispose(): void { + this._isDisposed = true; + this._beginResults.clear(); + super.dispose(); + } + + private async _captureSafely(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, location: 'begin' | 'end', workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise { + try { + return await this._capture(context, sessionUri, telemetryMessageId, location, workingDirectory, baseBranch, isContextCurrent); + } catch (error) { + this._logService.warn(`[AgentHostRepoInfoTelemetry] Failed to capture ${location} repo info: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + private async _capture(telemetryContext: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, location: 'begin' | 'end', workingDirectory: URI | undefined, persistedBaseBranch: string | undefined, isContextCurrent: () => boolean): Promise { + if (!workingDirectory || !isContextCurrent() || (!telemetryContext.restrictedTelemetryEnabled && !telemetryContext.isInternal)) { + return undefined; + } + + const [gitState, untrackedPaths] = await Promise.all([ + this._gitService.getSessionGitState(workingDirectory), + this._gitService.getUntrackedPaths(workingDirectory), + ]); + const upstreamRemote = gitState?.upstreamBranchName?.split('/')[0]; + const fetchRemoteUrls = await this._gitService.getFetchRemoteUrls(workingDirectory, upstreamRemote); + const remote = fetchRemoteUrls + ?.map(url => resolveRepoInfoRemote(url, this._gitHubEndpointService.getEnterpriseHost())) + .find((candidate): candidate is IResolvedRepoInfoRemote => candidate !== undefined); + if (!remote) { + return undefined; + } + + const baseBranch = persistedBaseBranch ?? gitState?.upstreamBranchName ?? gitState?.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); + const [headBranchName, headCommitHash] = await Promise.all([ + gitState?.branchName ? Promise.resolve(gitState.branchName) : this._gitService.getCurrentBranch(workingDirectory), + this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch), + ]); + if (!headCommitHash) { + return undefined; + } + const repoInfo: IRepoInfoContext = { ...remote, headCommitHash, headBranchName }; + const safety = await this._gitService.getBranchDiffSafetyInfo(workingDirectory, headCommitHash); + if (!safety) { + return undefined; + } + if (safety.hasVirtualFileSystem) { + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'virtualFileSystem', 0, 0, 0); + } + if (safety.baselineCommitTimestamp === undefined || Date.now() - safety.baselineCommitTimestamp > MAX_MERGE_BASE_AGE_MS) { + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'mergeBaseTooOld', 0, 0, 0); + } + if (safety.commitCount === undefined || safety.commitCount >= MAX_DIFF_COMMITS) { + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'tooManyCommits', 0, 0, 0); + } + const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + if (!tree) { + return undefined; + } + + const fileDiffs = await this._gitService.computeFileDiffsBetweenRefs(workingDirectory, { + sessionUri, + fromRef: headCommitHash, + toRef: tree, + }); + if (!fileDiffs) { + return undefined; + } + if (fileDiffs.length === 0) { + return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'noChanges', safety.workspaceFileCount, 0, 0); + } + if (fileDiffs.length > MAX_CHANGES) { + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'tooManyChanges', safety.workspaceFileCount, fileDiffs.length, 0); + } + + const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + const untracked = new Set(untrackedPaths ?? []); + const descriptors = fileDiffs.map(diff => this._describeFileDiff(repositoryRoot, diff, untracked)); + if (descriptors.some(descriptor => descriptor === undefined)) { + return undefined; + } + const resolvedDescriptors = descriptors as IRepoInfoFileDescriptor[]; + const fileRelativePaths = JSON.stringify([...new Set(resolvedDescriptors.map(descriptor => descriptor.newPath ?? descriptor.oldPath).filter((path): path is string => path !== undefined))]); + if (telemetryContext.copilotIgnoreEnabled !== false) { + return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'success', safety.workspaceFileCount, fileDiffs.length, 0, fileRelativePaths); + } + let patchTooLarge = false; + const limiter = new Limiter<{ readonly uri: string; readonly originalUri: string; readonly renameUri: string | undefined; readonly status: string; readonly diff: string }>(DIFF_PATCH_CONCURRENCY); + const diffs = await Promise.all(resolvedDescriptors.map(descriptor => limiter.queue(async () => { + const paths = [descriptor.oldPath, descriptor.newPath].filter((path): path is string => path !== undefined); + const result = await this._gitService.getDiffPatchBetweenRefs(workingDirectory, { fromRef: headCommitHash, toRef: tree, paths, maxBuffer: MAX_DIFFS_JSON_BYTES }); + if (!result) { + throw new Error(`Failed to compute diff for ${paths.join(', ')}`); + } + if (result.tooLarge) { + patchTooLarge = true; + } + return { + uri: descriptor.uri, + originalUri: descriptor.originalUri, + renameUri: descriptor.renameUri, + status: descriptor.status, + diff: truncateRepoInfoDiff(result.patch ?? '', descriptor.uri), + }; + }))); + if (patchTooLarge) { + return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'diffTooLarge', safety.workspaceFileCount, fileDiffs.length, MAX_DIFFS_JSON_BYTES + 1, fileRelativePaths); + } + const diffsJSON = JSON.stringify(diffs); + const measurement = measureRepoInfoDiffsJSON(diffsJSON); + if (measurement.tooLarge) { + return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'diffTooLarge', safety.workspaceFileCount, fileDiffs.length, measurement.diffSizeBytes, fileRelativePaths); + } + return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'success', safety.workspaceFileCount, fileDiffs.length, measurement.diffSizeBytes, fileRelativePaths, diffsJSON); + } + + private async _reportIfTreeUnchanged(telemetryContext: IAgentHostRestrictedTelemetryContext, isContextCurrent: () => boolean, telemetryMessageId: string, location: 'begin' | 'end', repoInfo: IRepoInfoContext, workingDirectory: URI, capturedTree: string, stableResult: 'success' | 'noChanges' | 'diffTooLarge', workspaceFileCount: number, changedFileCount: number, diffSizeBytes: number, fileRelativePaths?: string, diffsJSON?: string): Promise { + const currentTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + if (!currentTree || currentTree !== capturedTree) { + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'filesChanged', workspaceFileCount, changedFileCount, 0); + } + return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, stableResult, workspaceFileCount, changedFileCount, diffSizeBytes, fileRelativePaths, diffsJSON); + } + + private _describeFileDiff(repositoryRoot: URI, diff: ISessionFileDiff, untrackedPaths: ReadonlySet): IRepoInfoFileDescriptor | undefined { + const beforeUri = diff.before?.uri; + const afterUri = diff.after?.uri; + const oldPath = beforeUri ? relativePath(repositoryRoot, URI.parse(beforeUri)) : undefined; + const newPath = afterUri ? relativePath(repositoryRoot, URI.parse(afterUri)) : undefined; + if ((!oldPath && !newPath) || (!beforeUri && !afterUri)) { + return undefined; + } + const uri = afterUri ?? beforeUri!; + let status: IRepoInfoFileDescriptor['status']; + if (!beforeUri) { + status = newPath && untrackedPaths.has(newPath) ? 'UNTRACKED' : 'INDEX_ADDED'; + } else if (!afterUri) { + status = 'DELETED'; + } else if (beforeUri !== afterUri) { + status = 'INDEX_RENAMED'; + } else { + status = 'MODIFIED'; + } + return { + uri, + originalUri: beforeUri ?? uri, + renameUri: status === 'INDEX_RENAMED' ? afterUri : undefined, + status, + oldPath, + newPath, + }; + } + + private _report(telemetryContext: IAgentHostRestrictedTelemetryContext, isContextCurrent: () => boolean, telemetryMessageId: string, location: 'begin' | 'end', repoInfo: IRepoInfoContext, result: AgentHostRepoInfoResult, workspaceFileCount: number, changedFileCount: number, diffSizeBytes: number, fileRelativePaths?: string, diffsJSON?: string): AgentHostRepoInfoResult { + if (this._isDisposed || !isContextCurrent()) { + return result; + } + this._reporter.reportRepoInfo(telemetryContext, { + telemetryMessageId, + location, + remoteUrl: repoInfo.remoteUrl, + repoId: repoInfo.repoId, + repoType: repoInfo.repoType, + headCommitHash: repoInfo.headCommitHash, + headBranchName: repoInfo.headBranchName, + fileRelativePaths, + diffsJSON, + result, + isActiveRepository: 'true', + workspaceFileCount, + changedFileCount, + diffSizeBytes, + }); + return result; + } +} + +function truncateRepoInfoDiff(diff: string, uri: string): string { + if (diff.length <= MAX_DIFF_SIZE) { + return diff; + } + return `${diff.substring(0, MAX_DIFF_SIZE)}\n... Diff truncated (exceeded ${MAX_DIFF_SIZE} characters) for ${uri}`; +} \ No newline at end of file diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts index 304ba11ad8bbf..2516047b7585c 100644 --- a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -40,6 +40,8 @@ export interface IAgentHostInternalTelemetryContext { export interface IAgentHostRestrictedTelemetryContext extends IAgentHostInternalTelemetryContext { readonly restrictedTelemetryEnabled: boolean; readonly telemetryEndpoint: string | undefined; + /** Whether content exclusion is enabled; undefined when account discovery could not determine it. */ + readonly copilotIgnoreEnabled?: boolean; } export interface IAgentHostInternalTelemetrySink { diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 22238fc9affd2..c3c1c02d00973 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -10,7 +10,7 @@ import { AgentSession } from '../common/agentService.js'; import type { MessageAttachment, SessionInputRequestKind, ToolDefinition } from '../common/state/protocol/state.js'; import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; -import { multiplexProperties, type IAgentHostRestrictedTelemetry } from './agentHostRestrictedTelemetry.js'; +import { multiplexProperties, type IAgentHostRestrictedTelemetry, type IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -114,6 +114,25 @@ export interface IAgentHostSkillContentReadReport { pluginVersion: string | undefined; } +export type AgentHostRepoInfoResult = 'success' | 'filesChanged' | 'diffTooLarge' | 'noChanges' | 'tooManyChanges' | 'mergeBaseTooOld' | 'virtualFileSystem' | 'tooManyCommits'; + +export interface IAgentHostRepoInfoReport { + telemetryMessageId: string; + location: 'begin' | 'end'; + remoteUrl: string; + repoId: string; + repoType: 'github' | 'ado'; + headCommitHash: string; + headBranchName: string | undefined; + fileRelativePaths: string | undefined; + diffsJSON: string | undefined; + result: AgentHostRepoInfoResult; + isActiveRepository: 'true'; + workspaceFileCount: number; + changedFileCount: number; + diffSizeBytes: number; +} + export interface IAgentHostToolCallStalledEvent { provider: string; agentSessionId: string; @@ -375,6 +394,36 @@ export class AgentHostTelemetryReporter { restricted.sendInternalMSFTTelemetryEvent('skillContentRead', plaintextProps); } + reportRepoInfo(context: IAgentHostRestrictedTelemetryContext, report: IAgentHostRepoInfoReport): void { + const restricted = this._restricted; + if (!restricted) { + return; + } + const properties = { + remoteUrl: report.remoteUrl, + repoId: report.repoId, + repoType: report.repoType, + headCommitHash: report.headCommitHash, + headBranchName: report.headBranchName, + fileRelativePaths: report.fileRelativePaths, + diffsJSON: report.diffsJSON, + result: report.result, + isActiveRepository: report.isActiveRepository, + location: report.location, + telemetryMessageId: report.telemetryMessageId, + }; + const measurements = { + workspaceFileCount: report.workspaceFileCount, + changedFileCount: report.changedFileCount, + diffSizeBytes: report.diffSizeBytes, + repoIndex: 0, + repoCount: 1, + }; + const { headBranchName: _, fileRelativePaths: _2, ...internalProperties } = properties; + restricted.sendEnhancedGHTelemetryEventForContext(context, 'request.repoInfo', multiplexProperties(properties), measurements); + restricted.sendInternalMSFTTelemetryEventForContext(context, 'request.repoInfo', multiplexProperties(internalProperties), measurements); + } + turnCompleted(report: IAgentHostTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnCompleted', { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3c5f469982d05..320fec006cea8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2787,6 +2787,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: launchPlan.activeClientToolSet, resolveMcpChildId: name => findMcpChildId(activeClient.pluginController.getCustomizations(), name), serverToolHost: this._serverToolHost, + isLaunchTokenCurrent: () => this._githubToken === launchPlan.githubToken, }, ); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 068ff8e11b6ef..f057c4e9cc19f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -30,8 +30,9 @@ import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilo import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle } from '../../common/agentService.js'; +import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; @@ -47,6 +48,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; +import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; @@ -55,7 +57,8 @@ import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfi import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; -import { ICopilotApiService } from '../shared/copilotApiService.js'; +import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; +import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestrictedTelemetry.js'; import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; import { getEffectiveMcpServerCustomizations, McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; import { appendSdkToolResultContent, mapSessionEvents } from './mapSessionEvents.js'; @@ -366,6 +369,8 @@ export interface ICopilotAgentSessionOptions { * the future) and exposes SDK tool handlers that execute them in-process. */ readonly serverToolHost?: IAgentServerToolHost; + /** Returns whether the token that launched this session is still the active account token. */ + readonly isLaunchTokenCurrent?: () => boolean; /** * Platform used to compute the SDK sandbox policy. Defaults to @@ -649,6 +654,7 @@ export class CopilotAgentSession extends Disposable { private readonly _onDidSessionProgress: Emitter; private readonly _sessionLauncher: ICopilotSessionLauncher; private readonly _launchPlan: CopilotSessionLaunchPlan; + private readonly _isLaunchTokenStillCurrent: () => boolean; private readonly _shellManager: ShellManager | undefined; private readonly _workingDirectory: URI | undefined; private readonly _customizationDirectory: URI | undefined; @@ -698,6 +704,13 @@ export class CopilotAgentSession extends Disposable { /** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */ private readonly _telemetryReporter: AgentHostTelemetryReporter; + private readonly _repoInfoTelemetry: AgentHostRepoInfoTelemetry; + private _activeRepoInfoTurn: { + readonly sdkTurnId: string; + readonly telemetryMessageId: string; + cancelled: boolean; + begin: Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined>; + } | undefined; constructor( options: ICopilotAgentSessionOptions, @@ -719,12 +732,14 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress = options.onDidSessionProgress; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; + this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._shellManager = options.shellManager; this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; this._platform = options.platform ?? process.platform; this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); + this._repoInfoTelemetry = this._register(this._instantiationService.createInstance(AgentHostRepoInfoTelemetry, this._telemetryReporter)); this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); @@ -2839,6 +2854,62 @@ export class CopilotAgentSession extends Disposable { } } + private async _beginRepoInfoTelemetry(telemetryMessageId: string, isCurrent: () => boolean): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> { + let resolved: { readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined; + try { + resolved = await this._resolveRepoInfoTelemetryContext(); + } catch (error) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to resolve repository info telemetry context: ${getErrorMessage(error)}`); + return undefined; + } + if (!resolved || this._store.isDisposed || !isCurrent()) { + return undefined; + } + await this._repoInfoTelemetry.reportBegin(resolved.context, this.sessionUri.toString(), telemetryMessageId, this._workingDirectory, resolved.baseBranch, isCurrent); + return resolved; + } + + private async _endRepoInfoTelemetry(telemetryMessageId: string, resolved: { readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined, isCurrent: () => boolean): Promise { + if (!resolved || this._store.isDisposed || !isCurrent()) { + return; + } + await this._repoInfoTelemetry.reportEnd(resolved.context, this.sessionUri.toString(), telemetryMessageId, this._workingDirectory, resolved.baseBranch, isCurrent); + } + + private async _resolveRepoInfoTelemetryContext(): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostDisableRepoInfoTelemetryConfigKey) === true) { + return undefined; + } + const githubToken = this._launchPlan.githubToken; + if (!githubToken) { + return undefined; + } + const [rawContext, baseBranch] = await Promise.all([ + this._copilotApiService.resolveRestrictedTelemetryContext(githubToken), + this._databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH), + ]); + if (!rawContext.restrictedTelemetryEnabled && !rawContext.isInternal) { + return undefined; + } + return { context: this._toRepoInfoTelemetryContext(rawContext), baseBranch }; + } + + private _isLaunchTokenCurrent(): boolean { + return this._launchPlan.githubToken !== undefined && this._isLaunchTokenStillCurrent(); + } + + private _toRepoInfoTelemetryContext(context: IRestrictedTelemetryContext): IAgentHostRestrictedTelemetryContext { + return { + restrictedTelemetryEnabled: context.restrictedTelemetryEnabled, + trackingId: context.trackingId, + telemetryEndpoint: context.telemetryEndpoint ? `${context.telemetryEndpoint.replace(/\/+$/, '')}/telemetry` : undefined, + isInternal: context.isInternal === true, + userName: context.userName, + isVscodeTeamMember: context.isVscodeTeamMember === true, + copilotIgnoreEnabled: context.copilotIgnoreEnabled, + }; + } + // ---- event wiring ------------------------------------------------------- private _subscribeToEvents(): void { @@ -4096,6 +4167,18 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnStart(e => { this._currentTurn?.markRunning(); this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); + if (!e.agentId) { + const telemetryMessageId = this._currentTurn?.id ?? e.data.turnId; + const turn: NonNullable = { + sdkTurnId: e.data.turnId, + telemetryMessageId, + cancelled: false, + begin: Promise.resolve(undefined), + }; + const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent(); + turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, isCurrent); + this._activeRepoInfoTurn = turn; + } })); this._register(wrapper.onIntent(e => { @@ -4117,10 +4200,25 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); + const activeRepoInfoTurn = this._activeRepoInfoTurn; + if (!e.agentId && activeRepoInfoTurn?.sdkTurnId === e.data.turnId) { + const isCurrent = () => !activeRepoInfoTurn.cancelled && this._isLaunchTokenCurrent(); + void activeRepoInfoTurn.begin.then(resolved => this._endRepoInfoTelemetry(activeRepoInfoTurn.telemetryMessageId, resolved, isCurrent)).finally(() => { + if (this._activeRepoInfoTurn === activeRepoInfoTurn) { + this._activeRepoInfoTurn = undefined; + } + }); + } })); this._register(wrapper.onAbort(e => { this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); + if (this._activeRepoInfoTurn) { + const activeRepoInfoTurn = this._activeRepoInfoTurn; + activeRepoInfoTurn.cancelled = true; + void activeRepoInfoTurn.begin.finally(() => this._repoInfoTelemetry.clearTurn(activeRepoInfoTurn.telemetryMessageId)); + this._activeRepoInfoTurn = undefined; + } })); this._register(wrapper.onToolUserRequested(e => { diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 0d547b3c6923d..519c418405b84 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -83,6 +83,7 @@ export interface ICopilotUtilityChatCompletionRequest { */ interface ICopilotUserResponse { readonly login?: string; + readonly copilotignore_enabled?: boolean; readonly endpoints?: { readonly api?: string; readonly telemetry?: string; @@ -101,6 +102,7 @@ interface ICachedClient { readonly telemetryEndpoint?: string; /** The CAPI `endpoints.api` base URL discovered (or overridden) for this token, if any. */ readonly apiEndpoint?: string; + readonly copilotIgnoreEnabled?: boolean; } /** @@ -434,6 +436,8 @@ export interface IRestrictedTelemetryContext { readonly userName?: string; /** Whether the token identifies a VS Code team member. */ readonly isVscodeTeamMember?: boolean; + /** Whether content exclusion is enabled; undefined when discovery could not determine it. */ + readonly copilotIgnoreEnabled?: boolean; } export interface ICopilotApiService { @@ -906,6 +910,7 @@ export class CopilotApiService implements ICopilotApiService { isInternal: token.isInternal, userName: client.login, isVscodeTeamMember: token.isVscodeTeamMember, + copilotIgnoreEnabled: client.copilotIgnoreEnabled, }; } @@ -1015,6 +1020,7 @@ export class CopilotApiService implements ICopilotApiService { login: envelope.login, telemetryEndpoint: envelope.endpoints?.telemetry, apiEndpoint: envelope.endpoints?.api, + copilotIgnoreEnabled: envelope.copilotignore_enabled, }; } diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index cc9bac0250e9d..8085e87c7ad7b 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -264,6 +264,10 @@ export function createNoopGitService(): import('../../common/agentHostGitService overlayPathIntoTree: async () => undefined, diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, + getFetchRemoteUrls: async () => undefined, + getUntrackedPaths: async () => [], + getBranchDiffSafetyInfo: async () => undefined, + getDiffPatchBetweenRefs: async () => undefined, }; } diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index e2d82d5585e04..1387385dc2496 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -28,7 +28,7 @@ import type { IClientTransport, IProtocolTransport } from '../../common/state/se import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentHostCodexAgentEnabledSettingId, AgentHostSystemProxyEnabledSettingId } from '../../common/agentService.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AUTO_REPLY_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AUTO_REPLY_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; @@ -658,6 +658,23 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual(getRootConfig(updatedAutoReplyEnabled), { [AgentHostAutoReplyEnabledConfigKey]: false }); }); + test('forwards the repo-info telemetry debug switch on connect and change', async () => { + const configurationService = new TestConfigurationService({ [DISABLE_REPO_INFO_TELEMETRY_SETTING_ID]: true }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + + await connectClient(client, transport); + + const disabled = findRootConfigNotification(transport.sentMessages, AgentHostDisableRepoInfoTelemetryConfigKey); + assert.deepStrictEqual(getRootConfig(disabled), { [AgentHostDisableRepoInfoTelemetryConfigKey]: true }); + + transport.sentMessages.length = 0; + await configurationService.setUserConfiguration(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, false); + fireConfigurationChange(configurationService, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID); + + const enabled = findLastRootConfigNotification(transport.sentMessages, AgentHostDisableRepoInfoTelemetryConfigKey); + assert.deepStrictEqual(getRootConfig(enabled), { [AgentHostDisableRepoInfoTelemetryConfigKey]: false }); + }); + test('forwards terminal auto-approve rules on connect', async () => { const configurationService = new TestConfigurationService({ [TERMINAL_AUTO_APPROVE_SETTING_ID]: { diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 1b1d94c5d0e72..9e1cc4f9fa338 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -70,6 +70,10 @@ class TestGitService implements IAgentHostGitService { async overlayPathIntoTree(): Promise { return undefined; } async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } + async getFetchRemoteUrls(): Promise { return undefined; } + async getUntrackedPaths(): Promise<[]> { return []; } + async getBranchDiffSafetyInfo(): Promise { return undefined; } + async getDiffPatchBetweenRefs(): Promise { return undefined; } } class TestCopilotApiService implements ICopilotApiService { diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 2135ec16e0cc3..60866142cb4cb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -56,6 +56,10 @@ class TestGitService implements IAgentHostGitService { async overlayPathIntoTree(): Promise { return undefined; } async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } + async getFetchRemoteUrls(): Promise { return undefined; } + async getUntrackedPaths(): Promise<[]> { return []; } + async getBranchDiffSafetyInfo(): Promise { return undefined; } + async getDiffPatchBetweenRefs(): Promise { return undefined; } } function setup(disposables: Pick, opts?: { readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; session: URI } { diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 31f2f1a1086b2..5f244cd89cabc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -351,6 +351,48 @@ suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => { assert.deepStrictEqual(treePaths, ['fresh.txt', 'new.txt']); }); + (hasGit ? test : test.skip)('computes bounded per-file patches from an immutable working-tree snapshot', async () => { + const fs = await import('fs/promises'); + const { dir, run } = initRepo(); + await fs.writeFile(join(dir, 'tracked.txt'), 'before\n'); + run('add', '.'); + run('commit', '-q', '-m', 'init'); + const baseline = run('rev-parse', 'HEAD').toString().trim(); + + await fs.writeFile(join(dir, 'tracked.txt'), 'after\n'); + await fs.writeFile(join(dir, 'untracked.txt'), 'new\n'); + const tree = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(tree); + const fileDiffs = await svc!.computeFileDiffsBetweenRefs(URI.file(dir), { sessionUri: 'copilot:/s', fromRef: baseline, toRef: tree }); + assert.ok(fileDiffs); + const snapshots = await Promise.all(fileDiffs.map(async fileDiff => { + const before = fileDiff.before?.uri ? URI.parse(fileDiff.before.uri).path.split('/').pop() : undefined; + const after = fileDiff.after?.uri ? URI.parse(fileDiff.after.uri).path.split('/').pop() : undefined; + const paths = [before, after].filter((path): path is string => path !== undefined); + const patch = await svc!.getDiffPatchBetweenRefs(URI.file(dir), { fromRef: baseline, toRef: tree, paths, maxBuffer: 900 * 1024 }); + return { before, after, patch }; + })); + + assert.deepStrictEqual(snapshots.map(snapshot => ({ + before: snapshot.before, + after: snapshot.after, + tooLarge: snapshot.patch?.tooLarge, + containsExpectedContent: snapshot.after === 'tracked.txt' + ? snapshot.patch?.patch?.includes('-before\n+after') + : snapshot.patch?.patch?.includes('+new'), + })).sort((a, b) => (a.after ?? '').localeCompare(b.after ?? '')), [{ + before: 'tracked.txt', + after: 'tracked.txt', + tooLarge: false, + containsExpectedContent: true, + }, { + before: undefined, + after: 'untracked.txt', + tooLarge: false, + containsExpectedContent: true, + }]); + }); + (hasGit && !isWindows ? test : test.skip)('captureWorkingTreeAsTree returns undefined when staging fails', async () => { const fs = await import('fs/promises'); const { dir } = initRepo(); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index de63295160c6e..6238df7ce6aac 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; +import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseFetchRemoteUrls, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; @@ -155,6 +155,27 @@ suite('AgentHostGitService', () => { }); }); + test('orders fetch remote URLs with origin first and excludes push URLs', () => { + assert.deepStrictEqual(parseFetchRemoteUrls([ + 'upstream\tgit@github.com:microsoft/vscode.git (fetch)', + 'origin\thttps://github.com/me/vscode.git (push)', + 'origin\thttps://github.com/me/vscode.git (fetch)', + ].join('\n')), [ + 'https://github.com/me/vscode.git', + 'git@github.com:microsoft/vscode.git', + ]); + }); + + test('prefers the branch upstream remote before origin', () => { + assert.deepStrictEqual(parseFetchRemoteUrls([ + 'origin\thttps://github.com/me/vscode.git (fetch)', + 'upstream\thttps://github.com/microsoft/vscode.git (fetch)', + ].join('\n'), 'upstream'), [ + 'https://github.com/microsoft/vscode.git', + 'https://github.com/me/vscode.git', + ]); + }); + suite('parseUntrackedPaths', () => { test('returns empty for empty/undefined output', () => { assert.deepStrictEqual(parseUntrackedPaths(undefined), []); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 1a3dbf12b2ec9..775254b519e96 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -97,6 +97,10 @@ class TestGitService implements IAgentHostGitService { async overlayPathIntoTree(): Promise { return undefined; } async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } + async getFetchRemoteUrls(): Promise { return undefined; } + async getUntrackedPaths(): Promise<[]> { return []; } + async getBranchDiffSafetyInfo(): Promise { return undefined; } + async getDiffPatchBetweenRefs(): Promise { return undefined; } } class TestOctoKitService implements IAgentHostOctoKitService { diff --git a/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts new file mode 100644 index 0000000000000..a235158614ca5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../log/common/log.js'; +import type { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import type { ISessionFileDiff } from '../../common/state/sessionState.js'; +import { AgentHostRepoInfoTelemetry, measureRepoInfoDiffsJSON, resolveRepoInfoRemote } from '../../node/agentHostRepoInfoTelemetry.js'; +import type { IAgentHostRepoInfoReport } from '../../node/agentHostTelemetryReporter.js'; +import { createNoopGitService } from '../common/sessionTestHelpers.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; + +const restrictedContext = { + restrictedTelemetryEnabled: true, + trackingId: 'tracking-id', + telemetryEndpoint: 'https://telemetry.example/telemetry', + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + copilotIgnoreEnabled: false, +}; + +suite('AgentHostRepoInfoTelemetry', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves dotcom and configured Enterprise remotes', () => { + assert.deepStrictEqual({ + https: resolveRepoInfoRemote('https://github.com/microsoft/vscode.git', undefined), + ssh: resolveRepoInfoRemote('git@github.com:microsoft/vscode.git', undefined), + enterprise: resolveRepoInfoRemote('ssh://git@ghe.example.com/octo/repo.git', 'ghe.example.com'), + enterprisePort: resolveRepoInfoRemote('https://ghe.example.com:8443/octo/repo.git', 'ghe.example.com:8443'), + adoHttps: resolveRepoInfoRemote('https://dev.azure.com/Org/Project/_git/Repo', undefined), + adoSsh: resolveRepoInfoRemote('git@ssh.dev.azure.com:v3/Org/Project/Repo', undefined), + wrongEnterprise: resolveRepoInfoRemote('https://other.example.com/octo/repo.git', 'ghe.example.com'), + }, { + https: { remoteUrl: 'https://github.com/microsoft/vscode.git', repoId: 'microsoft/vscode', repoType: 'github' }, + ssh: { remoteUrl: 'https://github.com/microsoft/vscode.git', repoId: 'microsoft/vscode', repoType: 'github' }, + enterprise: { remoteUrl: 'https://ghe.example.com/octo/repo.git', repoId: 'octo/repo', repoType: 'github' }, + enterprisePort: { remoteUrl: 'https://ghe.example.com:8443/octo/repo.git', repoId: 'octo/repo', repoType: 'github' }, + adoHttps: { remoteUrl: 'https://dev.azure.com/Org/Project/_git/Repo', repoId: 'org/project/repo', repoType: 'ado' }, + adoSsh: { remoteUrl: 'https://ssh.dev.azure.com/v3/Org/Project/Repo', repoId: 'org/project/repo', repoType: 'ado' }, + wrongEnterprise: undefined, + }); + }); + + test('applies the legacy byte and multiplex character limits', () => { + assert.deepStrictEqual({ + atCharacterLimit: measureRepoInfoDiffsJSON('x'.repeat(50 * 8192)).tooLarge, + overCharacterLimit: measureRepoInfoDiffsJSON('x'.repeat(50 * 8192 + 1)).tooLarge, + overByteLimit: measureRepoInfoDiffsJSON('\u20ac'.repeat(307_201)).tooLarge, + }, { + atCharacterLimit: false, + overCharacterLimit: true, + overByteLimit: true, + }); + }); + + test('emits structured begin and end snapshots against the branch baseline', async () => { + const root = URI.file('/repo'); + const snapshots = ['tree-begin', 'tree-begin', 'tree-end', 'tree-end']; + const patches: string[] = []; + const fileDiff: ISessionFileDiff = { + before: { uri: URI.joinPath(root, 'src/a.ts').toString(), content: { uri: 'git-blob://before' } }, + after: { uri: URI.joinPath(root, 'src/a.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 1, removed: 1 }, + }; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => root, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['git@github.com:microsoft/vscode.git'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 42 }), + captureWorkingTreeAsTree: async () => snapshots.shift(), + computeFileDiffsBetweenRefs: async () => [fileDiff], + getDiffPatchBetweenRefs: async (_workingDirectory, options) => { + patches.push(options.toRef); + return { patch: `patch-${options.toRef}`, tooLarge: false }; + }, + }; + const reports: IAgentHostRepoInfoReport[] = []; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ + reportRepoInfo: (_context, report) => reports.push(report), + }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + await collector.reportEnd(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + + assert.deepStrictEqual({ + patches, + reports: reports.map(report => ({ + telemetryMessageId: report.telemetryMessageId, + location: report.location, + result: report.result, + remoteUrl: report.remoteUrl, + repoId: report.repoId, + headCommitHash: report.headCommitHash, + headBranchName: report.headBranchName, + fileRelativePaths: report.fileRelativePaths, + diffs: report.diffsJSON ? JSON.parse(report.diffsJSON) : undefined, + workspaceFileCount: report.workspaceFileCount, + changedFileCount: report.changedFileCount, + })), + }, { + patches: ['tree-begin', 'tree-end'], + reports: [{ + telemetryMessageId: 'turn-1', + location: 'begin', + result: 'success', + remoteUrl: 'https://github.com/microsoft/vscode.git', + repoId: 'microsoft/vscode', + headCommitHash: 'base', + headBranchName: 'feature', + fileRelativePaths: JSON.stringify(['src/a.ts']), + diffs: [{ + uri: URI.joinPath(root, 'src/a.ts').toString(), + originalUri: URI.joinPath(root, 'src/a.ts').toString(), + status: 'MODIFIED', + diff: 'patch-tree-begin', + }], + workspaceFileCount: 42, + changedFileCount: 1, + }, { + telemetryMessageId: 'turn-1', + location: 'end', + result: 'success', + remoteUrl: 'https://github.com/microsoft/vscode.git', + repoId: 'microsoft/vscode', + headCommitHash: 'base', + headBranchName: 'feature', + fileRelativePaths: JSON.stringify(['src/a.ts']), + diffs: [{ + uri: URI.joinPath(root, 'src/a.ts').toString(), + originalUri: URI.joinPath(root, 'src/a.ts').toString(), + status: 'MODIFIED', + diff: 'patch-tree-end', + }], + workspaceFileCount: 42, + changedFileCount: 1, + }], + }); + }); + + test('skips Git collection when restricted telemetry is unavailable', async () => { + let gitCalls = 0; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getSessionGitState: async () => { gitCalls++; return undefined; }, + }; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: () => { } }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin({ ...restrictedContext, restrictedTelemetryEnabled: false, isInternal: false }, 'agent-session://copilot/s1', 'turn-1', URI.file('/repo'), undefined, () => true); + + assert.strictEqual(gitCalls, 0); + }); + + test('does not emit end after a begin result that legacy suppresses', async () => { + const root = URI.file('/repo'); + const fileDiffs: ISessionFileDiff[] = Array.from({ length: 101 }, (_, index) => ({ + after: { uri: URI.joinPath(root, `file-${index}.txt`).toString(), content: { uri: `git-blob://after/${index}` } }, + diff: { added: 1, removed: 0 }, + })); + let snapshots = 0; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => root, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 42 }), + captureWorkingTreeAsTree: async () => { snapshots++; return 'tree'; }, + computeFileDiffsBetweenRefs: async () => fileDiffs, + }; + const reports: IAgentHostRepoInfoReport[] = []; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + await collector.reportEnd(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + + assert.deepStrictEqual({ snapshots, results: reports.map(report => report.result) }, { snapshots: 1, results: ['tooManyChanges'] }); + }); + + test('withholds diff content when content exclusion may be active', async () => { + const root = URI.file('/repo'); + let patchCalls = 0; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => root, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/Microsoft/VSCode'], + getUntrackedPaths: async () => ['new.txt'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 2 }), + captureWorkingTreeAsTree: async () => 'tree', + computeFileDiffsBetweenRefs: async () => [{ + after: { uri: URI.joinPath(root, 'new.txt').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 1, removed: 0 }, + }], + getDiffPatchBetweenRefs: async () => { patchCalls++; return { patch: 'secret', tooLarge: false }; }, + }; + const reports: IAgentHostRepoInfoReport[] = []; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin({ ...restrictedContext, copilotIgnoreEnabled: true }, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + + assert.deepStrictEqual({ + patchCalls, + report: reports[0] && { + repoId: reports[0].repoId, + fileRelativePaths: reports[0].fileRelativePaths, + diffsJSON: reports[0].diffsJSON, + result: reports[0].result, + }, + }, { + patchCalls: 0, + report: { + repoId: 'microsoft/vscode', + fileRelativePaths: JSON.stringify(['new.txt']), + diffsJSON: undefined, + result: 'success', + }, + }); + }); + + test('reports filesChanged when the working tree changes during collection', async () => { + const root = URI.file('/repo'); + const trees = ['tree-before', 'tree-after']; + const reports: IAgentHostRepoInfoReport[] = []; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => root, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 1 }), + captureWorkingTreeAsTree: async () => trees.shift(), + computeFileDiffsBetweenRefs: async () => [{ + before: { uri: URI.joinPath(root, 'a.txt').toString(), content: { uri: 'git-blob://before' } }, + after: { uri: URI.joinPath(root, 'a.txt').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 1, removed: 1 }, + }], + getDiffPatchBetweenRefs: async () => ({ patch: '-before\n+after', tooLarge: false }), + }; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + + assert.deepStrictEqual(reports.map(report => ({ result: report.result, diffsJSON: report.diffsJSON, fileRelativePaths: report.fileRelativePaths })), [{ + result: 'filesChanged', + diffsJSON: undefined, + fileRelativePaths: undefined, + }]); + }); + + test('marks untracked files and truncates each diff at the legacy limit', async () => { + const root = URI.file('/repo'); + const reports: IAgentHostRepoInfoReport[] = []; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => root, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], + getUntrackedPaths: async () => ['new.txt'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 1 }), + captureWorkingTreeAsTree: async () => 'tree', + computeFileDiffsBetweenRefs: async () => [{ + after: { uri: URI.joinPath(root, 'new.txt').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 1, removed: 0 }, + }], + getDiffPatchBetweenRefs: async () => ({ patch: 'x'.repeat(100_001), tooLarge: false }), + }; + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + + await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); + + const diffs = JSON.parse(reports[0].diffsJSON ?? '[]'); + assert.deepStrictEqual({ + status: diffs[0]?.status, + diffLength: diffs[0]?.diff.length, + truncated: diffs[0]?.diff.endsWith(`... Diff truncated (exceeded 100000 characters) for ${URI.joinPath(root, 'new.txt').toString()}`), + }, { + status: 'UNTRACKED', + diffLength: 100_001 + `... Diff truncated (exceeded 100000 characters) for ${URI.joinPath(root, 'new.txt').toString()}`.length, + truncated: true, + }); + }); +}); \ No newline at end of file diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index 154fbd9e836c3..c36879cea10e9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -9,7 +9,7 @@ import { hash } from '../../../../base/common/hash.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agentService.js'; import type { ToolDefinition } from '../../common/state/protocol/state.js'; -import { IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +import { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; interface IRestrictedCall { @@ -42,11 +42,15 @@ class TestRestrictedTelemetryService implements ITelemetryService, IAgentHostRes sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { this.enhancedEvents.push({ eventName, properties }); } - sendEnhancedGHTelemetryEventForContext(): void { } + sendEnhancedGHTelemetryEventForContext(_context: IAgentHostRestrictedTelemetryContext, eventName: string, properties?: TelemetryProps): void { + this.enhancedEvents.push({ eventName, properties }); + } sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { this.internalEvents.push({ eventName, properties }); } - sendInternalMSFTTelemetryEventForContext(): void { } + sendInternalMSFTTelemetryEventForContext(_context: IAgentHostInternalTelemetryContext, eventName: string, properties?: TelemetryProps): void { + this.internalEvents.push({ eventName, properties }); + } setCopilotTrackingId(): void { } setRestrictedTelemetryEndpoint(): void { } setRestrictedTelemetryEnabled(): void { } @@ -191,6 +195,73 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.internalEvents, [expected]); }); + test('repoInfo gates collection and multiplexes sink-specific properties', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.reportRepoInfo({ + restrictedTelemetryEnabled: true, + trackingId: 'tracking-id', + telemetryEndpoint: 'https://telemetry.example/telemetry', + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + }, { + telemetryMessageId: 'turn-1', + location: 'begin', + remoteUrl: 'https://github.com/microsoft/vscode', + repoId: 'microsoft/vscode', + repoType: 'github', + headCommitHash: 'abc', + headBranchName: 'feature', + fileRelativePaths: JSON.stringify(['src/a.ts']), + diffsJSON: 'x'.repeat(8193), + result: 'success', + isActiveRepository: 'true', + workspaceFileCount: 10, + changedFileCount: 1, + diffSizeBytes: 8193, + }); + + assert.deepStrictEqual({ + enhanced: service.enhancedEvents[0], + internal: service.internalEvents[0], + }, { + enhanced: { + eventName: 'request.repoInfo', + properties: { + remoteUrl: 'https://github.com/microsoft/vscode', + repoId: 'microsoft/vscode', + repoType: 'github', + headCommitHash: 'abc', + headBranchName: 'feature', + fileRelativePaths: JSON.stringify(['src/a.ts']), + diffsJSON: 'x'.repeat(8192), + diffsJSON_02: 'x', + result: 'success', + isActiveRepository: 'true', + location: 'begin', + telemetryMessageId: 'turn-1', + }, + }, + internal: { + eventName: 'request.repoInfo', + properties: { + remoteUrl: 'https://github.com/microsoft/vscode', + repoId: 'microsoft/vscode', + repoType: 'github', + headCommitHash: 'abc', + diffsJSON: 'x'.repeat(8192), + diffsJSON_02: 'x', + result: 'success', + isActiveRepository: 'true', + location: 'begin', + telemetryMessageId: 'turn-1', + }, + }, + }); + }); + test('skillContentRead drops the version when no plugin name is known, matching the extension', () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b88216c31c3ca..0537fcb7caa70 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1625,6 +1625,10 @@ suite('AgentService (node dispatcher)', () => { overlayPathIntoTree: async () => undefined, diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, + getFetchRemoteUrls: async () => undefined, + getUntrackedPaths: async () => [], + getBranchDiffSafetyInfo: async () => undefined, + getDiffPatchBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); @@ -1725,6 +1729,10 @@ suite('AgentService (node dispatcher)', () => { overlayPathIntoTree: async () => undefined, diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, + getFetchRemoteUrls: async () => undefined, + getUntrackedPaths: async () => [], + getBranchDiffSafetyInfo: async () => undefined, + getDiffPatchBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const agent = new MockAgent('copilot'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2d48972da4a4d..5316bae55cd1d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -186,6 +186,10 @@ class TestAgentHostGitService implements IAgentHostGitService { async overlayPathIntoTree(): Promise { return undefined; } async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } + async getFetchRemoteUrls(): Promise { return undefined; } + async getUntrackedPaths(): Promise<[]> { return []; } + async getBranchDiffSafetyInfo(): Promise { return undefined; } + async getDiffPatchBetweenRefs(): Promise { return undefined; } } class TestAgentHostTerminalManager implements IAgentHostTerminalManager { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4a6b3dafe1809..e02686bbdcacb 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -38,14 +38,18 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentH import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; -import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; +import { createNoopGitService, createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; import { OtelData } from '../../common/otlp/otlpLogEmitter.js'; import { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import type { IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, IAgentHostInternalTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; // ---- Mock CopilotSession (SDK level) ---------------------------------------- @@ -232,6 +236,8 @@ class TestCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; apiEndpoint: string | undefined; + restrictedTelemetryContext: IRestrictedTelemetryContext = { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; + restrictedTelemetryContextError: Error | undefined; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; @@ -240,7 +246,12 @@ class TestCopilotApiService implements ICopilotApiService { async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } async utilityChatCompletion(_githubToken: string, _request: ICopilotUtilityChatCompletionRequest): Promise { throw new Error('not used'); } - async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveRestrictedTelemetryContext() { + if (this.restrictedTelemetryContextError) { + throw this.restrictedTelemetryContextError; + } + return this.restrictedTelemetryContext; + } async resolveApiEndpoint() { return this.apiEndpoint; } } @@ -344,6 +355,11 @@ async function createAgentSession(disposables: DisposableStore, options?: { platform?: NodeJS.Platform; githubToken?: string; copilotApiEndpoint?: string; + gitService?: IAgentHostGitService; + gitHubEndpointService?: IAgentHostGitHubEndpointService; + restrictedTelemetryContext?: IRestrictedTelemetryContext; + restrictedTelemetryContextError?: Error; + isLaunchTokenCurrent?: () => boolean; }): Promise<{ session: CopilotAgentSession; runtime: ICopilotSessionRuntime; @@ -416,8 +432,14 @@ async function createAgentSession(disposables: DisposableStore, options?: { const services = new ServiceCollection(); services.set(ILogService, options?.logService ?? new NullLogService()); services.set(ITelemetryService, options?.telemetryService ?? new NullTelemetryServiceShape()); + services.set(IAgentHostGitService, options?.gitService ?? createNoopGitService()); + services.set(IAgentHostGitHubEndpointService, options?.gitHubEndpointService ?? createTestGitHubEndpointService()); const copilotApiService = new TestCopilotApiService(); copilotApiService.apiEndpoint = options?.copilotApiEndpoint; + if (options?.restrictedTelemetryContext) { + copilotApiService.restrictedTelemetryContext = options.restrictedTelemetryContext; + } + copilotApiService.restrictedTelemetryContextError = options?.restrictedTelemetryContextError; services.set(ICopilotApiService, copilotApiService); const storedFileContents = new Map(Object.entries(options?.fileContents ?? {})); services.set(IFileService, { @@ -508,6 +530,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory: options?.workingDirectory, serverToolHost: options?.serverToolHost, platform: options?.platform ?? 'linux', + isLaunchTokenCurrent: options?.isLaunchTokenCurrent, }, )); @@ -6069,6 +6092,170 @@ suite('CopilotAgentSession', () => { }); }); + suite('repoInfo telemetry', () => { + class CapturingRestrictedTelemetryService implements ITelemetryService, IAgentHostRestrictedTelemetry { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sendErrorTelemetry = true; + readonly sessionId = 'sessionId'; + readonly machineId = 'machineId'; + readonly sqmId = 'sqmId'; + readonly devDeviceId = 'devDeviceId'; + readonly firstSessionDate = 'firstSessionDate'; + readonly events: Array<{ destination: 'enhanced' | 'internal'; eventName: string; properties: TelemetryProps | undefined }> = []; + + publicLog(): void { } + publicLog2(): void { } + publicLogError(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } + sendGHTelemetryEvent(): void { } + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.events.push({ destination: 'enhanced', eventName, properties }); + } + sendEnhancedGHTelemetryEventForContext(_context: IAgentHostRestrictedTelemetryContext, eventName: string, properties?: TelemetryProps): void { + this.events.push({ destination: 'enhanced', eventName, properties }); + } + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.events.push({ destination: 'internal', eventName, properties }); + } + sendInternalMSFTTelemetryEventForContext(_context: IAgentHostInternalTelemetryContext, eventName: string, properties?: TelemetryProps): void { + this.events.push({ destination: 'internal', eventName, properties }); + } + setCopilotTrackingId(): void { } + setRestrictedTelemetryEndpoint(): void { } + setRestrictedTelemetryEnabled(): void { } + setInternalTelemetryContext(): void { } + } + + test('captures begin and end for the root turn only', async () => { + const workingDirectory = URI.file('/repo'); + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => workingDirectory, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 10 }), + captureWorkingTreeAsTree: async () => 'tree', + computeFileDiffsBetweenRefs: async () => [], + }; + const telemetryService = new CapturingRestrictedTelemetryService(); + const { mockSession } = await createAgentSession(disposables, { + workingDirectory, + gitService, + telemetryService, + githubToken: 'github-token', + restrictedTelemetryContext: { + restrictedTelemetryEnabled: true, + trackingId: 'tracking-id', + telemetryEndpoint: 'https://telemetry.example', + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + copilotIgnoreEnabled: false, + }, + }); + + mockSession.fire('assistant.turn_start', { turnId: 'subagent-turn' }, { agentId: 'subagent-1' }); + mockSession.fire('assistant.turn_end', { turnId: 'subagent-turn' }, { agentId: 'subagent-1' }); + mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); + await timeout(0); + mockSession.fire('assistant.turn_end', { turnId: 'root-turn' }); + await timeout(0); + + assert.deepStrictEqual(telemetryService.events + .filter(event => event.eventName === 'request.repoInfo') + .map(event => ({ destination: event.destination, location: event.properties?.location, telemetryMessageId: event.properties?.telemetryMessageId, result: event.properties?.result })), [ + { destination: 'enhanced', location: 'begin', telemetryMessageId: 'root-turn', result: 'noChanges' }, + { destination: 'internal', location: 'begin', telemetryMessageId: 'root-turn', result: 'noChanges' }, + { destination: 'enhanced', location: 'end', telemetryMessageId: 'root-turn', result: 'noChanges' }, + { destination: 'internal', location: 'end', telemetryMessageId: 'root-turn', result: 'noChanges' }, + ]); + }); + + test('drops an in-flight capture when the launch token is no longer current', async () => { + let tokenCurrent = true; + const workingDirectory = URI.file('/repo'); + const telemetryService = new CapturingRestrictedTelemetryService(); + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => workingDirectory, + getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), + getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], + resolveBranchBaselineCommit: async () => 'base', + getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 10 }), + captureWorkingTreeAsTree: async () => 'tree', + computeFileDiffsBetweenRefs: async () => [], + }; + const { mockSession } = await createAgentSession(disposables, { + workingDirectory, + gitService, + telemetryService, + githubToken: 'github-token', + isLaunchTokenCurrent: () => tokenCurrent, + restrictedTelemetryContext: { + restrictedTelemetryEnabled: true, + trackingId: 'tracking-id', + telemetryEndpoint: 'https://telemetry.example', + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + copilotIgnoreEnabled: false, + }, + }); + mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); + tokenCurrent = false; + await timeout(0); + + assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'request.repoInfo'), []); + }); + + test('does not touch Git when repo-info telemetry is disabled', async () => { + let gitCalls = 0; + const { mockSession } = await createAgentSession(disposables, { + workingDirectory: URI.file('/repo'), + githubToken: 'github-token', + rootValues: { [AgentHostDisableRepoInfoTelemetryConfigKey]: true }, + gitService: { + ...createNoopGitService(), + getSessionGitState: async () => { gitCalls++; return undefined; }, + }, + }); + + mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); + await timeout(0); + + assert.strictEqual(gitCalls, 0); + }); + + test('skips capture when repository telemetry context resolution fails', async () => { + const logService = new CapturingLogService(); + const telemetryService = new CapturingRestrictedTelemetryService(); + const { mockSession } = await createAgentSession(disposables, { + workingDirectory: URI.file('/repo'), + githubToken: 'github-token', + logService, + telemetryService, + restrictedTelemetryContextError: new Error('context failed'), + }); + + mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); + await timeout(0); + mockSession.fire('assistant.turn_end', { turnId: 'root-turn' }); + await timeout(0); + + assert.deepStrictEqual({ + events: telemetryService.events.filter(event => event.eventName === 'request.repoInfo'), + warnings: logService.warnings.map(warning => warning.message).filter(message => message.includes('repository info telemetry context')), + }, { + events: [], + warnings: ['[Copilot:test-session-1] Failed to resolve repository info telemetry context: context failed'], + }); + }); + }); + suite('instructionsCollected telemetry', () => { class CapturingTelemetryService implements ITelemetryService { diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index bc5d4e068a618..dc7eabdb08f32 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -43,6 +43,10 @@ class TestAgentHostGitService implements IAgentHostGitService { async overlayPathIntoTree(): Promise { return undefined; } async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } + async getFetchRemoteUrls(): Promise { return undefined; } + async getUntrackedPaths(): Promise<[]> { return []; } + async getBranchDiffSafetyInfo(): Promise { return undefined; } + async getDiffPatchBetweenRefs(): Promise { return undefined; } } suite('Copilot Git Project', () => { diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 8cdee4ed3be6b..2edb66553e4a6 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -132,6 +132,7 @@ suite('CopilotApiService', () => { if (url.endsWith('/copilot_internal/user')) { return new Response(JSON.stringify({ login: 'octocat', + copilotignore_enabled: true, endpoints: { api: 'https://api.githubcopilot.com', telemetry: 'https://telemetry.example' }, }), { status: 200 }); } @@ -154,6 +155,7 @@ suite('CopilotApiService', () => { isInternal: true, userName: 'octocat', isVscodeTeamMember: true, + copilotIgnoreEnabled: true, }); });