From 4fff98c54bce5264a5fefec196ef5d561a92a903 Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Fri, 17 Jul 2026 17:02:46 -0700 Subject: [PATCH] feat(agentHost): emit restricted repository info telemetry Capture immutable begin/end repository snapshots for root Copilot turns and emit the legacy request.repoInfo schema to enhanced GitHub and internal Microsoft telemetry. Reuse Agent Host Git baselines and tree snapshots so committed, working-tree, rename, delete, and untracked changes are represented without mutating the user's index. Bind every event to the session launch token's immutable telemetry context, suppress source diffs when content exclusion may be enabled, and detect account, abort, and mid-capture tree changes before sending. Match the legacy safety and payload limits, multiplex large diffs, support GitHub/GHE/Azure DevOps remotes, and forward the existing repo-info debug kill switch into Agent Host config. Refs microsoft/vscode-internalbacklog#8247 --- .../browser/remoteAgentHostProtocolClient.ts | 17 +- .../agentHost/common/agentHostGitService.ts | 22 ++ .../agentHost/common/agentHostSchema.ts | 12 + .../electron-browser/localAgentHostService.ts | 14 +- .../agentHost/node/agentHostGitService.ts | 112 ++++-- .../node/agentHostRepoInfoTelemetry.ts | 341 ++++++++++++++++++ .../node/agentHostRestrictedTelemetry.ts | 2 + .../node/agentHostTelemetryReporter.ts | 51 ++- .../agentHost/node/copilot/copilotAgent.ts | 1 + .../node/copilot/copilotAgentSession.ts | 102 +++++- .../node/shared/copilotApiService.ts | 6 + .../test/common/sessionTestHelpers.ts | 4 + .../remoteAgentHostProtocolClient.test.ts | 19 +- .../agentHostCommitOperationHandler.test.ts | 4 + ...HostDiscardChangesOperationHandler.test.ts | 4 + .../agentHostGitService.integrationTest.ts | 42 +++ .../test/node/agentHostGitService.test.ts | 23 +- ...entHostPullRequestOperationHandler.test.ts | 4 + .../node/agentHostRepoInfoTelemetry.test.ts | 292 +++++++++++++++ .../node/agentHostTelemetryReporter.test.ts | 77 +++- .../agentHost/test/node/agentService.test.ts | 8 + .../agentHost/test/node/copilotAgent.test.ts | 4 + .../test/node/copilotAgentSession.test.ts | 195 +++++++++- .../test/node/copilotGitProject.test.ts | 4 + .../node/shared/copilotApiService.test.ts | 2 + 25 files changed, 1327 insertions(+), 35 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 42deaf12f05f9b..5dd2f87ae2c878 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 329d03016995ac..1030d018592be3 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 7612ddae9bc740..7fa90ff42847cd 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 2a226c09cd4487..319a1124f2e187 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 834426710b52ba..d7dfe97c584651 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 00000000000000..cf70d4b522865e --- /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 304ba11ad8bbf1..2516047b7585c0 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 22238fc9affd23..c3c1c02d00973c 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 3c5f469982d050..320fec006cea86 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 068ff8e11b6ef2..f057c4e9cc19f4 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 0d547b3c6923d8..519c418405b849 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 cc9bac0250e9d2..8085e87c7ad7b6 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 e2d82d5585e049..1387385dc24962 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 1b1d94c5d0e720..9e1cc4f9fa3387 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 2135ec16e0cc34..60866142cb4cbd 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 31f2f1a1086b23..5f244cd89cabc6 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 de63295160c6ea..6238df7ce6aac2 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 1a3dbf12b2ec9f..775254b519e96b 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 00000000000000..a235158614ca51 --- /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 154fbd9e836c3c..c36879cea10e9f 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 b88216c31c3ca9..0537fcb7caa70a 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 2d48972da4a4d6..5316bae55cd1d3 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 4a6b3dafe18093..e02686bbdcacb3 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 bc5d4e068a6183..dc7eabdb08f323 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 8cdee4ed3be6b6..2edb66553e4a6d 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, }); });