diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 4deb57e109b..43762892b7d 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -37,12 +37,12 @@ import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type Completion import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; 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 { ITelemetryService, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID, TelemetryLevel, telemetryLevelEnabled } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, TERMINAL_AUTO_APPROVE_ENABLED_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 { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; -import { AgentHostClientConnectionKind, toClientConnectionTelemetryMeta } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionKind, toClientTelemetryMeta } from '../common/agentHostTelemetry.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 { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -319,6 +319,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC @ILogService private readonly _logService: ILogService, @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._resourceIdentity = identity; @@ -774,7 +775,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _clientConnectionTelemetryMeta(): { _meta: Record } | Record { - const meta = toClientConnectionTelemetryMeta(this._transport.clientConnectionKind); + const sendIdentity = telemetryLevelEnabled(this._telemetryService, TelemetryLevel.USAGE); + const machineId = sendIdentity ? this._telemetryService.machineId : undefined; + const devDeviceId = sendIdentity ? this._telemetryService.devDeviceId : undefined; + const meta = toClientTelemetryMeta(this._transport.clientConnectionKind, machineId, devDeviceId); return meta ? { _meta: meta } : {}; } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 6cb3a3538cc..a50d799db37 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -13,6 +13,7 @@ import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import type { IAgentServerToolHost } from './agentServerTools.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; +import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type Changeset, type ChatOrigin, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; import type { AuthRequiredParams, SessionAction, ChatAction } from './state/sessionActions.js'; @@ -395,6 +396,7 @@ export interface IAgentCreateSessionConfig { export interface IAgentChatContext { readonly resource: URI; readonly configurationResource: URI; + readonly clientTelemetryContext?: IAgentHostClientTelemetryContext; /** * The addressed chat's origin, taken verbatim from the host-owned chat * catalog, and exhaustive across every way a chat comes into existence: diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index b37ead0d3b2..7349a8c4570 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { ChangesSummary } from './state/protocol/state.js'; import type { ISessionFileDiff, URI as ProtocolURI } from './state/sessionState.js'; @@ -286,14 +287,14 @@ export interface IAgentHostChangesetService { * Hook called by `AgentSideEffects` after a tool call that produced * file edits completes. Schedules a debounced session-changeset recompute. */ - onToolCallEditsApplied(session: ProtocolURI, turnId: string): void; + onToolCallEditsApplied(session: ProtocolURI, turnId: string, clientContext?: IAgentHostClientTelemetryContext): void; /** * Hook called by `AgentSideEffects` when a turn completes. Cancels any * pending mid-turn debounce, then schedules a final session + uncommitted * recompute. Ordering matters — see implementation. */ - onTurnComplete(session: ProtocolURI, turnId: string | undefined): void; + onTurnComplete(session: ProtocolURI, turnId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): void; /** * Hook called by `AgentSideEffects` when a session is truncated (turns diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index 857d439592f..828a1561a12 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -35,6 +35,8 @@ export interface IAgentHostClientTelemetryContext { readonly connectionKind: AgentHostClientConnectionKind; readonly transportKind: AgentHostTransportKind; readonly hostLaunchKind: AgentHostLaunchKind; + readonly machineId?: string; + readonly devDeviceId?: string; } export function createUnknownAgentHostClientTelemetryContext(clientType: AgentHostClientType): IAgentHostClientTelemetryContext { @@ -47,11 +49,21 @@ export function createUnknownAgentHostClientTelemetryContext(clientType: AgentHo } const CLIENT_CONNECTION_KIND_META_KEY = 'vscode.clientConnectionKind'; +const CLIENT_MACHINE_ID_META_KEY = 'vscode.clientMachineId'; +const CLIENT_DEV_DEVICE_ID_META_KEY = 'vscode.clientDevDeviceId'; -export function toClientConnectionTelemetryMeta(connectionKind: AgentHostClientConnectionKind | undefined): Record | undefined { - return connectionKind === undefined || connectionKind === AgentHostClientConnectionKind.Unknown - ? undefined - : { [CLIENT_CONNECTION_KIND_META_KEY]: connectionKind }; +export function toClientTelemetryMeta(connectionKind: AgentHostClientConnectionKind | undefined, machineId: string | undefined, devDeviceId: string | undefined): Record | undefined { + const meta: Record = {}; + if (connectionKind !== undefined && connectionKind !== AgentHostClientConnectionKind.Unknown) { + meta[CLIENT_CONNECTION_KIND_META_KEY] = connectionKind; + } + if (machineId) { + meta[CLIENT_MACHINE_ID_META_KEY] = machineId; + } + if (devDeviceId) { + meta[CLIENT_DEV_DEVICE_ID_META_KEY] = devDeviceId; + } + return Object.keys(meta).length > 0 ? meta : undefined; } export function readClientConnectionKind(meta: Record | undefined): AgentHostClientConnectionKind { @@ -70,6 +82,19 @@ export function readClientConnectionKind(meta: Record | undefin } } +export function readClientMachineId(meta: Record | undefined): string | undefined { + return readClientTelemetryIdentity(meta, CLIENT_MACHINE_ID_META_KEY); +} + +export function readClientDevDeviceId(meta: Record | undefined): string | undefined { + return readClientTelemetryIdentity(meta, CLIENT_DEV_DEVICE_ID_META_KEY); +} + +function readClientTelemetryIdentity(meta: Record | undefined, key: string): string | undefined { + const value = meta?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + export function readAgentHostLaunchKind(value: string | undefined): AgentHostLaunchKind { switch (value) { case AgentHostLaunchKind.VSCodeMainProcess: diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 0e13990ab3e..f1917ffbcc8 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -48,6 +48,7 @@ import { resolveSessionRepositories } from './agentHostSessionRepositories.js'; import { dedupeSessionFileDiffs, evaluateMultiRootDiffSources } from './agentHostMultiRootDiff.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { reportAgentHostStaticChangesetComputed, reportAgentHostTurnChangesetComputed, type IMultiRootTurnDiffMetrics, type StaticChangesetOutcome, type TurnChangesetOutcome } from './agentHostChangesetTelemetry.js'; +import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; /** * Maximum number of per-repository git diffs a multi-folder fan-out runs at @@ -467,7 +468,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return this._computeTurnChangeset(session, turnId, false); } - private async _computeTurnChangeset(session: ProtocolURI, turnId: string, reportTelemetry: boolean): Promise { + private async _computeTurnChangeset(session: ProtocolURI, turnId: string, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise { const turnUri = this._stateManager.registerChangeset(buildTurnChangesetUri(session, turnId)); const stopWatch = StopWatch.create(); let outcome: TurnChangesetOutcome = 'error'; @@ -519,7 +520,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC folderCount: workingDirectories?.length ?? 0, ...(outcome === 'computed' && fileCount !== undefined ? { fileCount } : {}), multiRoot: result?.multiRoot, - }); + }, clientContext); } } } @@ -613,7 +614,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return this._computeUncommittedChangeset(session, undefined, false); } - private async _computeUncommittedChangeset(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean): Promise { + private async _computeUncommittedChangeset(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise { const uncommittedUri = this._stateManager.registerChangeset(buildUncommittedChangesetUri(session)); if (!this._hasSubscription(session, uncommittedUri)) { return uncommittedUri; @@ -676,7 +677,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC isMultiRoot: isMultiRootSession(workingDirectories), folderCount: workingDirectories?.length ?? 0, ...(outcome === 'computed' && fileCount !== undefined ? { fileCount } : {}), - }); + }, clientContext); } } @@ -1085,18 +1086,18 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // ---- Lifecycle hooks invoked by AgentSideEffects ----------------------- - onToolCallEditsApplied(session: ProtocolURI, turnId: string): void { - this._scheduleDebouncedDiffComputation(session, turnId); + onToolCallEditsApplied(session: ProtocolURI, turnId: string, clientContext?: IAgentHostClientTelemetryContext): void { + this._scheduleDebouncedDiffComputation(session, turnId, clientContext); // Per-turn URIs have no catalogue chip aggregates, so skip the // recompute entirely when no client is observing this turn. The // next subscriber will get a fresh snapshot from // `tryHandleSubscribe → computeTurnChangeset`. if (this._hasSubscription(session, buildTurnChangesetUri(session, turnId))) { - this._scheduleDebouncedTurnDiffComputation(session, turnId); + this._scheduleDebouncedTurnDiffComputation(session, turnId, clientContext); } } - onTurnComplete(session: ProtocolURI, turnId: string | undefined): void { + onTurnComplete(session: ProtocolURI, turnId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): void { // Ordering matters for cancellation: cancel any pending mid-turn // debounces first so the final turn-complete computes supersede // them. After that, schedule the final recomputes for the turn @@ -1106,16 +1107,16 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC if (turnId !== undefined) { this._cancelDebouncedTurnDiffComputation(session, turnId); if (this._hasSubscription(session, buildTurnChangesetUri(session, turnId))) { - this._scheduleTurnRecompute(session, turnId, true); + this._scheduleTurnRecompute(session, turnId, true, clientContext); } } if (this._hasSubscription(session, buildUncommittedChangesetUri(session))) { - this._scheduleUncommittedRecompute(session, turnId, true); + this._scheduleUncommittedRecompute(session, turnId, true, clientContext); } - this._scheduleStaticRecompute(session, 'branch', turnId, undefined, true); - this._scheduleStaticRecompute(session, 'session', turnId, undefined, true); + this._scheduleStaticRecompute(session, 'branch', turnId, undefined, true, clientContext); + this._scheduleStaticRecompute(session, 'session', turnId, undefined, true, clientContext); } onSessionTruncated(session: ProtocolURI): void { @@ -1132,11 +1133,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * makes sense for the SDK-tracked session-wide diff (which sees fresh * `tool_complete` events between turn boundaries). */ - private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void { + private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string, clientContext?: IAgentHostClientTelemetryContext): void { this._debouncedDiffTimers.set(session, disposableTimeout(() => { this._debouncedDiffTimers.deleteAndDispose(session); - this._scheduleStaticRecompute(session, 'branch', turnId); - this._scheduleStaticRecompute(session, 'session', turnId); + this._scheduleStaticRecompute(session, 'branch', turnId, undefined, false, clientContext); + this._scheduleStaticRecompute(session, 'session', turnId, undefined, false, clientContext); }, AgentHostChangesetService._DIFF_DEBOUNCE_MS)); } @@ -1154,11 +1155,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * `(session, turnId)` map key so a long-running per-turn compute * doesn't block the static session recompute path (and vice versa). */ - private _scheduleDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void { + private _scheduleDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string, clientContext?: IAgentHostClientTelemetryContext): void { const key = `${session}\u0000${turnId}`; this._perTurnDebouncedDiffTimers.set(key, disposableTimeout(() => { this._perTurnDebouncedDiffTimers.deleteAndDispose(key); - this._scheduleTurnRecompute(session, turnId); + this._scheduleTurnRecompute(session, turnId, false, clientContext); }, AgentHostChangesetService._DIFF_DEBOUNCE_MS)); } @@ -1178,12 +1179,12 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * `uncommitted` slots) run independently. Fire-and-forget — failures * are logged inside `computeTurnChangeset` and do not fail the turn. */ - private _scheduleTurnRecompute(session: ProtocolURI, turnId: string, reportTelemetry: boolean = false): void { - this._diffComputationSequencer.queue(`${session}\u0000turn\u0000${turnId}`, () => this._computeTurnChangeset(session, turnId, reportTelemetry).then(() => undefined)); + private _scheduleTurnRecompute(session: ProtocolURI, turnId: string, reportTelemetry: boolean = false, clientContext?: IAgentHostClientTelemetryContext): void { + this._diffComputationSequencer.queue(`${session}\u0000turn\u0000${turnId}`, () => this._computeTurnChangeset(session, turnId, reportTelemetry, clientContext).then(() => undefined)); } - private _scheduleUncommittedRecompute(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean = false): void { - this._diffComputationSequencer.queue(`${session}\u0000uncommitted`, () => this._computeUncommittedChangeset(session, turnId, reportTelemetry).then(() => undefined)); + private _scheduleUncommittedRecompute(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean = false, clientContext?: IAgentHostClientTelemetryContext): void { + this._diffComputationSequencer.queue(`${session}\u0000uncommitted`, () => this._computeUncommittedChangeset(session, turnId, reportTelemetry, clientContext).then(() => undefined)); } /** @@ -1192,8 +1193,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * stale `previousDiffs` reads. Fire-and-forget — failures are logged * but do not fail the turn. */ - private _scheduleStaticRecompute(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus, reportTelemetry: boolean = false): void { - this._diffComputationSequencer.queue(`${session}\u0000${kind}`, () => this._doComputeStaticChangeset(session, kind, changedTurnId, statusBeforeRefresh, reportTelemetry)); + private _scheduleStaticRecompute(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus, reportTelemetry: boolean = false, clientContext?: IAgentHostClientTelemetryContext): void { + this._diffComputationSequencer.queue(`${session}\u0000${kind}`, () => this._doComputeStaticChangeset(session, kind, changedTurnId, statusBeforeRefresh, reportTelemetry, clientContext)); } private _markStaticChangesetComputing(session: ProtocolURI, kind: StaticChangesetKind): ChangesetStatus | undefined { @@ -1209,7 +1210,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return status; } - private async _doComputeStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus, reportTelemetry: boolean = false): Promise { + private async _doComputeStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus, reportTelemetry: boolean = false, clientContext?: IAgentHostClientTelemetryContext): Promise { const changesetUri = staticChangesetUri(session, kind); const stopWatch = StopWatch.create(); const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); @@ -1229,7 +1230,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC folderCount: workingDirectories?.length ?? 0, ...(outcome === 'computed' ? { fileCount } : {}), ...(kind === 'session' ? { incrementalUsed, usedEditTrackerFallback } : {}), - }); + }, clientContext); } }; this._activeStaticComputes.add(changesetUri); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts index 8db61c5b67c..55436558c1b 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts @@ -6,6 +6,8 @@ import { URI } from '../../../base/common/uri.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from './agentHostTelemetryReporter.js'; /** The static changeset slot a compute was for. */ export type StaticChangesetTelemetryKind = 'branch' | 'session' | 'uncommitted'; @@ -41,7 +43,7 @@ export interface IStaticChangesetTelemetryData { * turn. Conditional fields are omitted when not applicable rather than sent as * fabricated defaults. */ -export function reportAgentHostStaticChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, data: IStaticChangesetTelemetryData): void { +export function reportAgentHostStaticChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, data: IStaticChangesetTelemetryData, clientContext?: IAgentHostClientTelemetryContext): void { reportChangesetComputed(telemetryService, session, turnId, { kind: data.kind, outcome: data.outcome, @@ -51,7 +53,7 @@ export function reportAgentHostStaticChangesetComputed(telemetryService: ITeleme ...(data.fileCount !== undefined ? { fileCount: data.fileCount } : {}), ...(data.incrementalUsed !== undefined ? { incrementalUsed: data.incrementalUsed } : {}), ...(data.usedEditTrackerFallback !== undefined ? { usedEditTrackerFallback: data.usedEditTrackerFallback } : {}), - }); + }, clientContext); } /** @@ -90,7 +92,7 @@ export interface ITurnChangesetTelemetryData { * The multi-root fan-out fields are only sent for multi-root turns; `fileCount` * only when computed. */ -export function reportAgentHostTurnChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string, data: ITurnChangesetTelemetryData): void { +export function reportAgentHostTurnChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string, data: ITurnChangesetTelemetryData, clientContext?: IAgentHostClientTelemetryContext): void { reportChangesetComputed(telemetryService, session, turnId, { kind: 'turn', outcome: data.outcome, @@ -103,7 +105,7 @@ export function reportAgentHostTurnChangesetComputed(telemetryService: ITelemetr nonGitFolderCount: data.multiRoot.nonGitFolderCount, trackedEditFallbackFolderCount: data.multiRoot.trackedEditFallbackFolderCount, } : {}), - }); + }, clientContext); } /** The changeset kind a compute was for: a static slot, or a per-turn diff. */ @@ -112,7 +114,7 @@ export type ChangesetComputedKind = StaticChangesetTelemetryKind | 'turn'; /** The union of static and per-turn compute outcomes. */ export type ChangesetComputedOutcome = StaticChangesetOutcome | TurnChangesetOutcome; -type ChangesetComputedEvent = { +type ChangesetComputedEvent = IAgentHostInitiatorTelemetry & { provider: string; agentSessionId: string; turnId?: string; @@ -129,7 +131,7 @@ type ChangesetComputedEvent = { trackedEditFallbackFolderCount?: number; }; -type ChangesetComputedClassification = { +type ChangesetComputedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; turnId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For a turn changeset, the turn whose changeset was computed; for a static changeset, the turn that drove the recompute when one did (absent for truncation/refresh recomputes).' }; @@ -152,8 +154,9 @@ type ChangesetComputedClassification = { * Shared emitter for `agentHost.changesetComputed`. Correlation (`provider`, * `agentSessionId`) is derived from `session`; `turnId` is included when set. */ -function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit): void { +function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit, clientContext?: IAgentHostClientTelemetryContext): void { telemetryService.publicLog2('agentHost.changesetComputed', { + ...toInitiatorTelemetry(clientContext), provider: URI.parse(session).scheme, agentSessionId: AgentSession.id(session), ...(turnId !== undefined ? { turnId } : {}), diff --git a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts index dbfa54f7fe6..02fb95eff77 100644 --- a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { StopWatch } from '../../../base/common/stopwatch.js'; +import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import type { ChatInputCompletedAction } from '../common/state/sessionActions.js'; import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ResponsePartKind, isAhpChatChannel, parseRequiredSessionUriFromChatUri, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatState } from '../common/state/sessionState.js'; import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; @@ -26,6 +27,7 @@ export class AgentHostInputRequestTracker { constructor( private readonly _reporter: AgentHostTelemetryReporter, private readonly _stopWatchFactory: () => Pick = () => StopWatch.create(true), + private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined, ) { } inputRequested(provider: string, session: string, turnId: string, request: ChatInputRequest): void { @@ -76,6 +78,7 @@ export class AgentHostInputRequestTracker { const answeredCount = questions.filter(question => this._isAnswered(answers[question.id])).length; this._reporter.askQuestionsToolInvoked({ + clientContext: this._getClientContext(timing.session, timing.turnId), provider: timing.provider, session: timing.session, requestId: timing.turnId, diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 6969a13677a..fa99a29cc9d 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -22,6 +22,7 @@ import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOpt import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; import { preserveProviderBackedRootConfigValues } from '../common/agentCustomizationSettings.js'; +import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; @@ -270,8 +271,8 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidChangeSessionTitle = this._register(new Emitter<{ session: string; title: string }>()); readonly onDidChangeSessionTitle: Event<{ session: string; title: string }> = this._onDidChangeSessionTitle.event; - private readonly _onDidChangeSessionConfig = this._register(new Emitter<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined }>()); - readonly onDidChangeSessionConfig: Event<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined }> = this._onDidChangeSessionConfig.event; + private readonly _onDidChangeSessionConfig = this._register(new Emitter<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }>()); + readonly onDidChangeSessionConfig: Event<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }> = this._onDidChangeSessionConfig.event; private readonly _onDidChangeSessionWorkingDirectories = this._register(new Emitter<{ session: string }>()); readonly onDidChangeSessionWorkingDirectories: Event<{ session: string }> = this._onDidChangeSessionWorkingDirectories.event; @@ -1355,8 +1356,8 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state and emitted with the client's origin * so the originating client can reconcile. */ - dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin): unknown { - return this._applyAndEmit(channel, action, origin); + dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin, clientContext?: IAgentHostClientTelemetryContext): unknown { + return this._applyAndEmit(channel, action, origin, clientContext); } /** @@ -1413,7 +1414,7 @@ export class AgentHostStateManager extends Disposable { } } - private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown { + private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined, clientContext?: IAgentHostClientTelemetryContext): unknown { let resultingState: unknown = undefined; if (action.type === ActionType.RootConfigChanged && action.replace) { action = { @@ -1458,7 +1459,7 @@ export class AgentHostStateManager extends Disposable { this._onDidChangeSessionTitle.fire({ session: key, title: newState.title }); } if (sessionAction.type === ActionType.SessionConfigChanged) { - this._onDidChangeSessionConfig.fire({ session: key, previous: previousState.config, current: newState.config }); + this._onDidChangeSessionConfig.fire({ session: key, previous: previousState.config, current: newState.config, clientContext }); } // The reducer returns the SAME state object when a working-directory // action is a no-op, so a reference change here means the effective diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 918d6498d9d..077659a35bb 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -16,12 +16,30 @@ import { ActionType } from '../common/state/sessionActions.js'; import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; import { multiplexProperties, type IAgentHostRestrictedTelemetry, type IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js'; -import type { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; -export interface IAgentHostExecutionModeChangedEvent { +export interface IAgentHostInitiatorTelemetry { + initiatorClientType?: AgentHostClientType; + initiatorConnectionKind?: AgentHostClientConnectionKind; + initiatorTransportKind?: AgentHostTransportKind; + hostLaunchKind?: AgentHostLaunchKind; + initiatorMachineId?: string; + initiatorDevDeviceId?: string; +} + +export type IAgentHostInitiatorClassification = { + initiatorClientType?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of VS Code client that initiated the event.' }; + initiatorConnectionKind?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The route the initiating client used to reach the agent host.' }; + initiatorTransportKind?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The physical transport on which the agent host received the initiating client action.' }; + hostLaunchKind?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' }; + initiatorMachineId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'FeatureInsight'; endpoint: 'MacAddressHash'; comment: 'The machine identifier of the VS Code client that initiated the event.' }; + initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The development device identifier of the VS Code client that initiated the event.' }; +}; + +export interface IAgentHostExecutionModeChangedEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -30,7 +48,7 @@ export interface IAgentHostExecutionModeChangedEvent { turnCount: number; } -export type IAgentHostExecutionModeChangedClassification = { +export type IAgentHostExecutionModeChangedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the mode change belongs to a subagent session.' }; @@ -48,6 +66,8 @@ export interface IAgentHostUserMessageSentEvent { initiatorClientType: AgentHostClientType; initiatorConnectionKind: AgentHostClientConnectionKind; initiatorTransportKind: AgentHostTransportKind; + initiatorMachineId?: string; + initiatorDevDeviceId?: string; agentSessionId: string; source: AgentHostUserMessageSentSource; isSubagentSession: boolean; @@ -65,6 +85,8 @@ export type IAgentHostUserMessageSentClassification = { initiatorClientType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of AHP client that initiated the user message.' }; initiatorConnectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The route the initiating client declared it used to reach the agent host.' }; initiatorTransportKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The physical transport on which the agent host received the initiating client action.' }; + initiatorMachineId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'FeatureInsight'; endpoint: 'MacAddressHash'; comment: 'The initiating VS Code client machine identifier.' }; + initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The initiating VS Code client development device identifier.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user message was sent directly or from the queued-message flow.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the message was sent to a subagent session.' }; @@ -88,6 +110,8 @@ export interface IAgentHostClientConnectionEvent { clientImplementationVersion: string | undefined; connectionKind: AgentHostClientConnectionKind; transportKind: AgentHostTransportKind; + clientMachineId?: string; + clientDevDeviceId?: string; protocolVersion: string; isReconnect: boolean; connectedClientCount: number; @@ -106,6 +130,8 @@ export type IAgentHostClientConnectionClassification = { clientImplementationVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The implementation version declared by the AHP client.' }; connectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The route the client declared it used to reach the agent host.' }; transportKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The physical transport accepted by the agent host.' }; + clientMachineId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'FeatureInsight'; endpoint: 'MacAddressHash'; comment: 'The connected VS Code client machine identifier.' }; + clientDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The connected VS Code client development device identifier.' }; protocolVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The negotiated AHP protocol version.' }; isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this client identifier was previously known to the agent host.' }; connectedClientCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of logical AHP clients with at least one live transport after this lifecycle change.' }; @@ -137,7 +163,11 @@ export type AgentHostModelTelemetryKind = 'trusted' | 'byok' | 'unknown'; type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit'; export type AgentHostTurnFailureStage = 'validation' | 'workingDirectory' | 'modelSelection' | 'sendMessage' | 'provider'; -export interface IAgentHostTurnCompletedEvent { +interface IAgentHostTurnAttributedReport { + clientContext?: IAgentHostClientTelemetryContext; +} + +export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -157,7 +187,7 @@ export interface IAgentHostTurnCompletedEvent { folderCount: number; } -export type IAgentHostTurnCompletedClassification = { +export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the agent host session.' }; @@ -179,7 +209,7 @@ export type IAgentHostTurnCompletedClassification = { comment: 'Tracks agent host turn performance including time to first visible progress and total turn duration.'; }; -export interface IAgentHostTurnFailedEvent { +export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -195,7 +225,7 @@ export interface IAgentHostTurnFailedEvent { callstack: string | undefined; } -export type IAgentHostTurnFailedClassification = { +export type IAgentHostTurnFailedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the failed agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -221,7 +251,7 @@ export interface IAgentHostTurnFailure { errorStack?: string; } -export interface IAgentHostTurnCompletedReport { +export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -306,7 +336,7 @@ function normalizeTurnActivityKind(activityKind: string): AgentHostTurnActivityT return turnActivityKindsByActionType[activityKind as keyof typeof turnActivityKindsByActionType] ?? 'other'; } -export interface IAgentHostTurnHungEvent { +export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -327,7 +357,7 @@ export interface IAgentHostTurnHungEvent { permissionLevel: string | undefined; } -export type IAgentHostTurnHungClassification = { +export type IAgentHostTurnHungClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the hung agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -350,7 +380,7 @@ export type IAgentHostTurnHungClassification = { comment: 'Tracks agent host turns that stop making progress for longer than the hang threshold, so permanently stuck sessions are visible as a positive signal instead of missing turnCompleted events.'; }; -export interface IAgentHostTurnHungReport { +export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -369,7 +399,7 @@ export interface IAgentHostTurnHungReport { permissionLevel: string | undefined; } -export interface IAgentHostHungTurnCompletedEvent { +export interface IAgentHostHungTurnCompletedEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -382,7 +412,7 @@ export interface IAgentHostHungTurnCompletedEvent { timeAfterHangMs: number; } -export type IAgentHostHungTurnCompletedClassification = { +export type IAgentHostHungTurnCompletedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the recovered agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -397,7 +427,7 @@ export type IAgentHostHungTurnCompletedClassification = { comment: 'Tracks agent host turns that complete after previously being reported as hung, so permanent hangs can be separated from merely slow ones.'; }; -export interface IAgentHostHungTurnCompletedReport { +export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -408,7 +438,7 @@ export interface IAgentHostHungTurnCompletedReport { timeAfterHangMs: number; } -export interface IAgentHostToolInvokedReport { +export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -422,7 +452,7 @@ export interface IAgentHostToolInvokedReport { modelTelemetryKind: AgentHostModelTelemetryKind | undefined; } -export interface IAgentHostAskQuestionsToolInvokedEvent { +export interface IAgentHostAskQuestionsToolInvokedEvent extends IAgentHostInitiatorTelemetry { requestId: string; questionCount: number; answeredCount: number; @@ -436,7 +466,7 @@ export interface IAgentHostAskQuestionsToolInvokedEvent { isSubagentSession: boolean; } -export type IAgentHostAskQuestionsToolInvokedClassification = { +export type IAgentHostAskQuestionsToolInvokedClassification = IAgentHostInitiatorClassification & { requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current request turn.' }; questionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of questions asked' }; answeredCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of questions that were answered' }; @@ -452,7 +482,7 @@ export type IAgentHostAskQuestionsToolInvokedClassification = { comment: 'Tracks usage of the AskQuestions tool for agent clarifications'; }; -export interface IAgentHostAskQuestionsToolInvokedReport { +export interface IAgentHostAskQuestionsToolInvokedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; requestId: string; @@ -467,7 +497,7 @@ export interface IAgentHostAskQuestionsToolInvokedReport { type AgentHostToolCallResponseType = 'success' | 'cancelled' | 'failed'; -export interface IAgentHostToolCallDetailsEvent { +export interface IAgentHostToolCallDetailsEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -486,7 +516,7 @@ export interface IAgentHostToolCallDetailsEvent { parallelToolCallsTotal: number; } -export type IAgentHostToolCallDetailsClassification = { +export type IAgentHostToolCallDetailsClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the tool-call aggregate belongs to a subagent session.' }; @@ -507,7 +537,7 @@ export type IAgentHostToolCallDetailsClassification = { comment: 'Records aggregate information about tool calls during an agent host turn.'; }; -export interface IAgentHostToolCallDetailsReport { +export interface IAgentHostToolCallDetailsReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -528,7 +558,7 @@ export interface IAgentHostToolCallDetailsReport { parallelToolCallsTotal: number; } -export interface IAgentHostToolApprovalReport { +export interface IAgentHostToolApprovalReport extends IAgentHostTurnAttributedReport { provider: string; session: string; turnId: string; @@ -541,7 +571,7 @@ export interface IAgentHostToolApprovalReport { type AgentHostToolApprovalConfirmKind = 'userAction' | 'setting' | 'confirmationNotNeeded' | 'denied'; -export interface IAgentHostToolApprovalEvent { +export interface IAgentHostToolApprovalEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -559,7 +589,7 @@ export interface IAgentHostToolApprovalEvent { requestUnsandboxedExecution: boolean | undefined; } -export type IAgentHostToolApprovalClassification = { +export type IAgentHostToolApprovalClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the tool approval belongs to a subagent session.' }; @@ -635,7 +665,7 @@ export interface IAgentHostRepoInfoReport { diffSizeBytes: number; } -export interface IAgentHostToolCallStalledEvent { +export interface IAgentHostToolCallStalledEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -645,7 +675,7 @@ export interface IAgentHostToolCallStalledEvent { stalledTimeMs: number; } -export type IAgentHostToolCallStalledClassification = { +export type IAgentHostToolCallStalledClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' }; @@ -657,7 +687,7 @@ export type IAgentHostToolCallStalledClassification = { comment: 'Tracks agent host tool calls that remain blocked beyond the stall threshold.'; }; -export interface IAgentHostToolCallStalledReport { +export interface IAgentHostToolCallStalledReport extends IAgentHostTurnAttributedReport { provider: string; session: string; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; @@ -666,7 +696,7 @@ export interface IAgentHostToolCallStalledReport { stalledTimeMs: number; } -export interface IAgentHostStalledToolCallCompletedEvent { +export interface IAgentHostStalledToolCallCompletedEvent extends IAgentHostInitiatorTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -678,7 +708,7 @@ export interface IAgentHostStalledToolCallCompletedEvent { timeAfterStallMs: number; } -export type IAgentHostStalledToolCallCompletedClassification = { +export type IAgentHostStalledToolCallCompletedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' }; @@ -692,7 +722,7 @@ export type IAgentHostStalledToolCallCompletedClassification = { comment: 'Tracks agent host tool calls that complete after previously exceeding the stall threshold.'; }; -export interface IAgentHostStalledToolCallCompletedReport { +export interface IAgentHostStalledToolCallCompletedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution | SessionInputRequestKind.ToolAuthentication; @@ -713,6 +743,17 @@ function toTelemetryModel(model: string | undefined, modelTelemetryKind: AgentHo return modelTelemetryKind === 'byok' ? 'byokModel' : 'unknown'; } +export function toInitiatorTelemetry(clientContext: IAgentHostClientTelemetryContext | undefined): IAgentHostInitiatorTelemetry { + return { + ...(clientContext?.clientType !== undefined && clientContext.clientType !== AgentHostClientType.Unknown ? { initiatorClientType: clientContext.clientType } : {}), + ...(clientContext?.connectionKind !== undefined && clientContext.connectionKind !== AgentHostClientConnectionKind.Unknown ? { initiatorConnectionKind: clientContext.connectionKind } : {}), + ...(clientContext?.transportKind !== undefined && clientContext.transportKind !== AgentHostTransportKind.Unknown ? { initiatorTransportKind: clientContext.transportKind } : {}), + ...(clientContext?.hostLaunchKind !== undefined && clientContext.hostLaunchKind !== AgentHostLaunchKind.Unknown ? { hostLaunchKind: clientContext.hostLaunchKind } : {}), + ...(clientContext?.machineId ? { initiatorMachineId: clientContext.machineId } : {}), + ...(clientContext?.devDeviceId ? { initiatorDevDeviceId: clientContext.devDeviceId } : {}), + }; +} + export class AgentHostTelemetryReporter { constructor(private readonly _telemetryService: ITelemetryService) { } @@ -723,8 +764,9 @@ export class AgentHostTelemetryReporter { return typeof ts.sendEnhancedGHTelemetryEvent === 'function' ? ts as IAgentHostRestrictedTelemetry : undefined; } - executionModeChanged(provider: string, session: string, previousMode: SessionMode, newMode: SessionMode, turnCount: number): void { + executionModeChanged(provider: string, session: string, previousMode: SessionMode, newMode: SessionMode, turnCount: number, clientContext?: IAgentHostClientTelemetryContext): void { this._telemetryService.publicLog2('agentHost.executionModeChanged', { + ...toInitiatorTelemetry(clientContext), provider, agentSessionId: AgentSession.id(session), isSubagentSession: isSubagentSession(session), @@ -745,6 +787,8 @@ export class AgentHostTelemetryReporter { initiatorClientType: clientContext.clientType, initiatorConnectionKind: clientContext.connectionKind, initiatorTransportKind: clientContext.transportKind, + ...(clientContext.machineId ? { initiatorMachineId: clientContext.machineId } : {}), + ...(clientContext.devDeviceId ? { initiatorDevDeviceId: clientContext.devDeviceId } : {}), agentSessionId: AgentSession.id(sessionUri), source, isSubagentSession: isSubagentSession(sessionUri), @@ -774,6 +818,8 @@ export class AgentHostTelemetryReporter { clientImplementationVersion: report.clientImplementationVersion, connectionKind: report.context.connectionKind, transportKind: report.context.transportKind, + ...(report.context.machineId ? { clientMachineId: report.context.machineId } : {}), + ...(report.context.devDeviceId ? { clientDevDeviceId: report.context.devDeviceId } : {}), protocolVersion: report.protocolVersion, isReconnect: report.isReconnect, connectedClientCount: report.connectedClientCount, @@ -879,6 +925,7 @@ export class AgentHostTelemetryReporter { const conversationId = AgentSession.id(session); const toolCounts = JSON.stringify(report.toolCounts); this._telemetryService.publicLog2('toolCallDetails', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: conversationId, isSubagentSession: isSubagentSession(session), @@ -930,6 +977,7 @@ export class AgentHostTelemetryReporter { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; const agentSessionId = AgentSession.id(session); this._telemetryService.publicLog2('chat.toolApproval', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId, isSubagentSession: isSubagentSession(session), @@ -1073,6 +1121,7 @@ export class AgentHostTelemetryReporter { const isSubagent = isSubagentChatUri(report.session) || isSubagentSession(session); const model = toTelemetryModel(report.model, report.modelTelemetryKind); this._telemetryService.publicLog2('agentHost.turnCompleted', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId, @@ -1094,6 +1143,7 @@ export class AgentHostTelemetryReporter { if (report.failure) { const { providerCallId, serviceRequestId } = readAgentErrorTelemetryMeta(report.failure.error); this._telemetryService.publicLogError2('agentHost.turnFailed', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId, @@ -1119,6 +1169,7 @@ export class AgentHostTelemetryReporter { turnHung(report: IAgentHostTurnHungReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnHung', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId: getTelemetryChatSessionId(report.session), @@ -1144,6 +1195,7 @@ export class AgentHostTelemetryReporter { hungTurnCompleted(report: IAgentHostHungTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.hungTurnCompleted', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), chatSessionId: getTelemetryChatSessionId(report.session), @@ -1162,7 +1214,8 @@ export class AgentHostTelemetryReporter { // previously emitted by `CopilotAgentSession`). Action signals are keyed // by their chat-channel URI, so normalize it back to the session URI. const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; - this._telemetryService.publicLog2('languageModelToolInvoked', { + this._telemetryService.publicLog2('languageModelToolInvoked', { + ...toInitiatorTelemetry(report.clientContext), result: report.result, chatSessionId: session, toolId: report.toolId, @@ -1180,6 +1233,7 @@ export class AgentHostTelemetryReporter { askQuestionsToolInvoked(report: IAgentHostAskQuestionsToolInvokedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('askQuestionsToolInvoked', { + ...toInitiatorTelemetry(report.clientContext), requestId: report.requestId, questionCount: report.questionCount, answeredCount: report.answeredCount, @@ -1197,6 +1251,7 @@ export class AgentHostTelemetryReporter { toolCallStalled(report: IAgentHostToolCallStalledReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.toolCallStalled', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), @@ -1210,6 +1265,7 @@ export class AgentHostTelemetryReporter { stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.stalledToolCallCompleted', { + ...toInitiatorTelemetry(report.clientContext), provider: report.provider, agentSessionId: AgentSession.id(session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts index 55aef7b6392..97a3068540b 100644 --- a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -6,6 +6,7 @@ import { disposableTimeout } from '../../../base/common/async.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; +import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import type { SessionToolAuthenticationRequest, SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js'; import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; import type { AgentHostModelTelemetryKind, AgentHostTelemetryReporter, IAgentHostToolInvokedReport } from './agentHostTelemetryReporter.js'; @@ -81,6 +82,7 @@ interface IToolCallTiming { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; modelResolvedFromUsage: boolean; + readonly clientContext: IAgentHostClientTelemetryContext | undefined; } interface IStalledToolCall { @@ -110,7 +112,10 @@ export class AgentHostToolCallTracker extends Disposable { private readonly _toolCallStallTimers = this._register(new DisposableMap()); private readonly _stalledToolCalls = new Map(); - constructor(private readonly _reporter: AgentHostTelemetryReporter) { + constructor( + private readonly _reporter: AgentHostTelemetryReporter, + private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined, + ) { super(); } @@ -127,6 +132,7 @@ export class AgentHostToolCallTracker extends Disposable { model: resolvedModel?.model ?? model, modelTelemetryKind: resolvedModel?.modelTelemetryKind ?? modelTelemetryKind, modelResolvedFromUsage: resolvedModel !== undefined, + clientContext: this._getClientContext(session, turnId), }); } @@ -182,6 +188,7 @@ export class AgentHostToolCallTracker extends Disposable { const resultSizeInCharacters = JSON.stringify(result).length; const report: IAgentHostToolInvokedReport = { + clientContext: timing.clientContext, provider: timing.provider, session: timing.session, turnId: timing.turnId, @@ -206,6 +213,7 @@ export class AgentHostToolCallTracker extends Disposable { if (stalled) { this._stalledToolCalls.delete(key); this._reporter.stalledToolCallCompleted({ + clientContext: timing.clientContext, provider: timing.provider, session: timing.session, blockerKind: stalled.blockerKind, @@ -228,8 +236,10 @@ export class AgentHostToolCallTracker extends Disposable { const stopWatch = StopWatch.create(true); this._toolCallStallTimers.set(key, disposableTimeout(() => { const stalledTimeMs = stopWatch.elapsed(); + const clientContext = this._toolCalls.get(toolCallKey)?.clientContext; this._stalledToolCalls.set(toolCallKey, { blockerKind: request.kind, completionStopWatch: StopWatch.create(true) }); this._reporter.toolCallStalled({ + clientContext, provider, session, blockerKind: request.kind, diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 4c8d85cc65b..25a664c5f6b 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -8,6 +8,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import type { SessionMode } from '../common/agentHostSchema.js'; +import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { canRefineContributor, toolSourceKindFromContributor } from './agentHostToolCallTracker.js'; import { SessionInputRequestKind } from '../common/state/protocol/state.js'; import type { ToolCallContributor } from '../common/state/sessionState.js'; @@ -57,6 +59,7 @@ interface ITurnTiming { readonly modelSelectionKind: 'default' | 'auto' | 'explicit'; readonly permissionLevel: string | undefined; readonly interactionMode: SessionMode | undefined; + readonly clientContext: IAgentHostClientTelemetryContext; firstProgressMs: number | undefined; // Hang watchdog state @@ -132,7 +135,7 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, permissionLevel: string | undefined, interactionMode: SessionMode | undefined): void { + turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), @@ -144,6 +147,7 @@ export class AgentHostTurnTracker extends Disposable { modelSelectionKind: model === undefined ? 'default' : model === 'auto' ? 'auto' : 'explicit', permissionLevel, interactionMode, + clientContext, firstProgressMs: undefined, quietStopWatch: StopWatch.create(false), lastActivityKind: TURN_ACTIVITY_NONE, @@ -291,6 +295,10 @@ export class AgentHostTurnTracker extends Disposable { return timing ? { model: timing.model, modelTelemetryKind: timing.modelTelemetryKind } : undefined; } + getClientTelemetryContext(session: string, turnId: string): IAgentHostClientTelemetryContext | undefined { + return this._turnTimings.get(this._key(session, turnId))?.clientContext; + } + turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): void { const key = this._key(session, turnId); const timing = this._turnTimings.get(key); @@ -300,6 +308,7 @@ export class AgentHostTurnTracker extends Disposable { this._disposeTurn(key, timing); this._reporter.turnCompleted({ + clientContext: timing.clientContext, provider: timing.provider, session: timing.session, turnId, @@ -320,6 +329,7 @@ export class AgentHostTurnTracker extends Disposable { // which distinguishes a permanent hang from a merely slow turn. if (timing.lastHangReason !== undefined) { this._reporter.hungTurnCompleted({ + clientContext: timing.clientContext, provider: timing.provider, session: timing.session, turnId, @@ -397,6 +407,7 @@ export class AgentHostTurnTracker extends Disposable { const userBlocker = this._firstUserBlocker(timing); const stuckTool = this._resolveStuckTool(timing, hangReason); this._reporter.turnHung({ + clientContext: timing.clientContext, provider: timing.provider, session: timing.session, turnId: timing.turnId, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d722e486d4a..6a29c67c621 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3547,7 +3547,7 @@ export class AgentService extends Disposable implements IAgentService { return; } } - this._stateManager.dispatchClientAction(channel, action, origin); + this._stateManager.dispatchClientAction(channel, action, origin, clientContext); if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); const editTelemetryEnabled = action.config[AgentHostEditTelemetryEnabledConfigKey]; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a27fb2e5a66..76742726b21 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -289,8 +289,8 @@ export class AgentSideEffects extends Disposable { this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._turnTracker = this._register(new AgentHostTurnTracker(this._telemetryReporter)); this.onDidStartTurn = this._turnTracker.onDidStartTurn; - this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter)); - this._inputRequestTracker = new AgentHostInputRequestTracker(this._telemetryReporter); + this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId))); + this._inputRequestTracker = new AgentHostInputRequestTracker(this._telemetryReporter, undefined, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId)); this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager, {})); this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, { sessionDataService: this._options.sessionDataService, @@ -324,7 +324,7 @@ export class AgentSideEffects extends Disposable { return; } - this._telemetryReporter.executionModeChanged(agent.id, e.session, previousMode, currentMode, sessionState.turns.length); + this._telemetryReporter.executionModeChanged(agent.id, e.session, previousMode, currentMode, sessionState.turns.length, e.clientContext); })); this._register(this._customizationEnablementService.onDidChange(event => { for (const session of event.sessions) { @@ -1066,14 +1066,15 @@ export class AgentSideEffects extends Disposable { // available across completed turns so it can be steered again. this._pendingSubagentSignals.delete(sessionKey, action.toolCallId); if (getToolFileEdits(action.result).length > 0) { - this._changesets.onToolCallEditsApplied(sessionUri, turnId); + this._changesets.onToolCallEditsApplied(sessionUri, turnId, this._turnTracker.getClientTelemetryContext(sessionKey, turnId)); } } if (action.type === ActionType.ChatTurnComplete) { + const clientContext = this._turnTracker.getClientTelemetryContext(sessionKey, turnId); this._completeTurn(sessionKey, turnId, 'success'); this._toolCallTracker.clearSession(sessionKey); - this._runTurnCompleteSideEffects(sessionKey, turnId); + this._runTurnCompleteSideEffects(sessionKey, turnId, clientContext); } if (action.type === ActionType.ChatTurnCancelled) { @@ -1083,9 +1084,10 @@ export class AgentSideEffects extends Disposable { } if (action.type === ActionType.ChatError) { + const clientContext = this._turnTracker.getClientTelemetryContext(sessionKey, turnId); this._completeTurn(sessionKey, turnId, 'error', { stage: 'provider', error: action.error }); this._toolCallTracker.clearSession(sessionKey); - this._captureTurnCheckpointAndRefresh(sessionKey, turnId); + this._captureTurnCheckpointAndRefresh(sessionKey, turnId, clientContext); this._markSessionUnread(sessionUri); } } @@ -1096,10 +1098,10 @@ export class AgentSideEffects extends Disposable { * before reading the effective working directories, so peer-chat / channel * turns report the correct count and multi-root flag. */ - private _captureTurnCheckpointAndRefresh(sessionKey: ProtocolURI, turnId: string): void { + private _captureTurnCheckpointAndRefresh(sessionKey: ProtocolURI, turnId: string, clientContext?: IAgentHostClientTelemetryContext): void { const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; const workingDirectories = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri)?.map(w => URI.parse(w)); - this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), URI.parse(sessionKey), turnId, workingDirectories).then(() => this._changesets.onTurnComplete(sessionUri, turnId), () => this._changesets.onTurnComplete(sessionUri, turnId)); + this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), URI.parse(sessionKey), turnId, workingDirectories).then(() => this._changesets.onTurnComplete(sessionUri, turnId, clientContext), () => this._changesets.onTurnComplete(sessionUri, turnId, clientContext)); } private _completeTurn(channel: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure): void { @@ -1113,7 +1115,7 @@ export class AgentSideEffects extends Disposable { * compute final diffs immediately, drain the next queued message, and * notify the host so it can refresh git state. */ - private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void { + private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): void { // Checkpoints, changesets and the host git-refresh notification are // scoped to the owning session's working tree, which peer chats // share. Normalize an additional-chat channel to its session for @@ -1141,13 +1143,13 @@ export class AgentSideEffects extends Disposable { // which a caller-supplied set would apply. const workingDirectories = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri)?.map(w => URI.parse(w)); this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), URI.parse(sessionKey), turnId, workingDirectories).then(() => { - this._changesets.onTurnComplete(sessionUri, turnId); + this._changesets.onTurnComplete(sessionUri, turnId, clientContext); }, err => { this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); - this._changesets.onTurnComplete(sessionUri, turnId); + this._changesets.onTurnComplete(sessionUri, turnId, clientContext); }); } else { - this._changesets.onTurnComplete(sessionUri, turnId); + this._changesets.onTurnComplete(sessionUri, turnId, clientContext); } this._tryConsumeNextQueuedMessage(sessionKey); this._options.onTurnComplete(sessionUri); @@ -1244,17 +1246,22 @@ export class AgentSideEffects extends Disposable { // Seed the subagent's opening request with the delegated task prompt, // supplied by the provider on the `subagent_started` signal. const turnId = generateUuid(); + const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); + const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(contentChatUri, parentTurnId) : undefined; this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, startedAt: new Date().toISOString(), message: { text: taskPrompt ?? '', origin: { kind: MessageKind.User } }, }); + const agent = this._options.getAgent(parentSessionUri); + if (agent) { + this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + } this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId); // Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested. - const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); if (parentTurnId) { const parentState = this._stateManager.getSessionState(contentChatUri); const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); @@ -1311,6 +1318,8 @@ export class AgentSideEffects extends Disposable { } const turnId = generateUuid(); + const parentTurnId = this._stateManager.getActiveTurnId(parentChatURI); + const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatURI, parentTurnId) : undefined; this._logService.info(`[AgentSideEffects] Resuming subagent turn: ${subagent.chatUri} (parent=${parentChatURI}, toolCallId=${toolCallId})`); this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnStarted, @@ -1318,6 +1327,10 @@ export class AgentSideEffects extends Disposable { startedAt: new Date().toISOString(), message: message ?? { text: '', origin: { kind: MessageKind.User } }, }); + const agent = this._options.getAgent(subagent.sessionUri); + if (agent) { + this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + } this._subagentChats.set({ ...subagent, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); } @@ -1584,7 +1597,7 @@ export class AgentSideEffects extends Disposable { const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, clientId, clientContext, channel, action.turnId, state, 'direct', attachments); const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, state, action.message.model?.id); - this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel, interactionMode); + this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel, interactionMode, clientContext); void this._sendTurnMessage({ agent, sessionChannel, @@ -1593,7 +1606,7 @@ export class AgentSideEffects extends Disposable { message: action.message, turnId: action.turnId, senderClientId: clientId, - clientType: clientContext.clientType, + clientContext, turnStopWatch, }); break; @@ -1651,7 +1664,7 @@ export class AgentSideEffects extends Disposable { if (agent) { const chat = URI.parse(channel); const session = parseRequiredSessionUriFromChatUri(channel); - agent.chats.abort(chat, this._chatContext(session, channel)).catch(err => { + agent.chats.abort(chat, { ...this._chatContext(session, channel), clientTelemetryContext: clientContext }).catch(err => { this._logService.error('[AgentSideEffects] abort failed', err); }); } @@ -2038,7 +2051,7 @@ export class AgentSideEffects extends Disposable { const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, sender.clientId, sender.clientContext, session, turnId, queuedState, 'queued', attachments); const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id); - this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel, interactionMode); + this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel, interactionMode, sender.clientContext); // Selection travels on the queued message; it is applied before sending. void this._sendTurnMessage({ agent, @@ -2048,7 +2061,7 @@ export class AgentSideEffects extends Disposable { message: msg.message, turnId, senderClientId: sender.clientId, - clientType: sender.clientContext.clientType, + clientContext: sender.clientContext, turnStopWatch, }); } @@ -2095,10 +2108,10 @@ export class AgentSideEffects extends Disposable { message: Message; turnId: string; senderClientId: string | undefined; - clientType: AgentHostClientType; + clientContext: IAgentHostClientTelemetryContext; turnStopWatch: StopWatch; }): Promise { - const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId, clientType, turnStopWatch } = options; + const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId, clientContext, turnStopWatch } = options; // Read-only chats reject user-dispatched turns. `interactivity` is the // general signal (e.g. subagent worker chats are `ReadOnly`), and an @@ -2137,13 +2150,14 @@ export class AgentSideEffects extends Disposable { // folder for folder sessions; undefined for workspace-less sessions. const resolvedWorkingDirectories = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text }); const chatContext = this._chatContext(options.sessionChannel, chat); + const clientOperationContext = { ...chatContext, clientTelemetryContext: clientContext }; const selectionUpdates: Promise[] = []; if (message.model) { failureStage = 'modelSelection'; - selectionUpdates.push(agent.chats.changeModel(chatUri, message.model, chatContext)); + selectionUpdates.push(agent.chats.changeModel(chatUri, message.model, clientOperationContext)); } - selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent, chatContext).catch(err => { + selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent, clientOperationContext).catch(err => { this._logService.error('[AgentSideEffects] changeAgent failed', err); })); @@ -2158,14 +2172,14 @@ export class AgentSideEffects extends Disposable { : []), ...(renameInstruction ? [renameInstruction] : []), ]; - const sendContext = hostInstructions.length ? { ...chatContext, hostInstructions } : chatContext; + const sendContext = { ...clientOperationContext, ...(hostInstructions.length ? { hostInstructions } : {}) }; if (this._cancelledTurnIds.get(turnChannel)?.has(turnId)) { return; } await this._checkpointService.captureTurnStartCheckpoint(URI.parse(sessionChannel), chatUri, turnId, resolvedWorkingDirectories); if (this._cancelledTurnIds.get(turnChannel)?.has(turnId)) { await this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), chatUri, turnId); return; } - await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectories, resolvedAttachments, turnId, senderClientId, clientType, sendContext); + await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectories, resolvedAttachments, turnId, senderClientId, clientContext.clientType, sendContext); } catch (err) { const failure = buildTurnFailure(failureStage, err); const error = failure.error; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 1bfe2219566..040067489e9 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -2268,6 +2268,7 @@ export class ClaudeAgent extends Disposable implements IAgent { // hypothetical caller forgets it. const effectiveTurnId = turnId ?? generateUuid(); const sendContext = this._requireChatContext(chat, operationContext, 'sendMessage'); + const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext?.clientTelemetryContext; const context = this._resolveChatContext(chat, sendContext); return this._sessionSequencer.queue(context.sequencerKey, async () => { @@ -2284,7 +2285,7 @@ export class ClaudeAgent extends Disposable implements IAgent { const turns = sideChat ? await this._reconstructTurns(session.sessionId, current.chat, session.subagents) : []; const sdkPrompt = prepareSideChatPrompt(prompt, turns, sideChat); const switchTransport = session.hasPendingTransportSwitch ? this._ensureAuthenticated(session.provisionalModel) : undefined; - await session.send(this._buildSdkPrompt(session.sessionId, sdkPrompt, attachments, effectiveTurnId), effectiveTurnId, current.configurationResource, workingDirectories, switchTransport, resolveAgentHostInstructions(operationContext)); + await session.send(this._buildSdkPrompt(session.sessionId, sdkPrompt, attachments, effectiveTurnId), effectiveTurnId, current.configurationResource, workingDirectories, switchTransport, resolveAgentHostInstructions(operationContext), clientTelemetryContext); if (workingDirectories) { await this._metadataStore.write(current.resource, { workingDirectories }); } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 8d5d345c289..7328078e019 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -20,6 +20,7 @@ import { ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { ClaudeRuntimeEffortLevel, toRuntimeEffortLevel, resolveClaudeEffort } from '../../common/claudeModelConfig.js'; import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; @@ -879,7 +880,7 @@ export class ClaudeAgentSession extends Disposable { * model / effort (set eagerly via {@link setModel}) is whatever * the SDK has been told. */ - async send(prompt: SDKUserMessage, turnId: string, resource: URI, workingDirectories?: readonly URI[], switchTransport?: ClaudeTransport, hostInstructions?: readonly string[]): Promise { + async send(prompt: SDKUserMessage, turnId: string, resource: URI, workingDirectories?: readonly URI[], switchTransport?: ClaudeTransport, hostInstructions?: readonly string[], clientContext?: IAgentHostClientTelemetryContext): Promise { const pipeline = this._requirePipeline(); if (workingDirectories) { this._replaceDesiredWorkingDirectories(workingDirectories); @@ -904,7 +905,7 @@ export class ClaudeAgentSession extends Disposable { await this._reconcileMcpServerEnablement(); this._hostInstructions = hostInstructions; try { - await pipeline.send(prompt, turnId); + await pipeline.send(prompt, turnId, clientContext); } finally { this._hostInstructions = undefined; } diff --git a/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts b/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts index f726151d92c..9d7ffd26645 100644 --- a/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts +++ b/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts @@ -8,6 +8,7 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import type { ClaudeMapperState } from './claudeMapSessionEvents.js'; import { getClaudeToolPath, isClaudeFileEditTool } from './claudeToolDisplay.js'; @@ -54,7 +55,7 @@ export class ClaudeFileEditObserver extends Disposable { * per-subagent: when a subagent emits the `tool_use`, its model * (not the parent's) is what we record. */ - private readonly _editToolPaths = new Map(); + private readonly _editToolPaths = new Map(); constructor( sessionUri: string, @@ -82,7 +83,7 @@ export class ClaudeFileEditObserver extends Disposable { * the SDK yields a canonical `'assistant'` message (full * `tool_use.input` available). */ - observeAssistant(message: Extract, mode?: PermissionMode): void { + observeAssistant(message: Extract, mode?: PermissionMode, clientContext?: IAgentHostClientTelemetryContext): void { const content = message.message.content; if (!Array.isArray(content)) { return; @@ -96,7 +97,7 @@ export class ClaudeFileEditObserver extends Disposable { if (!filePath) { continue; } - this._editToolPaths.set(block.id, { filePath, toolName: block.name, toolInput: block.input, modelId }); + this._editToolPaths.set(block.id, { filePath, toolName: block.name, toolInput: block.input, modelId, clientContext }); void this._editTracker.trackEditStart(filePath, mode).catch(err => this._logService.warn(`[ClaudeFileEditObserver] trackEditStart failed for ${filePath}: ${err}`)); } @@ -130,7 +131,7 @@ export class ClaudeFileEditObserver extends Disposable { this._editToolPaths.delete(block.tool_use_id); try { await this._editTracker.completeEdit(tracked.filePath); - const fileEdit = await this._editTracker.takeCompletedEdit(turnId, block.tool_use_id, tracked.filePath, tracked.toolName, tracked.toolInput, tracked.modelId); + const fileEdit = await this._editTracker.takeCompletedEdit(turnId, block.tool_use_id, tracked.filePath, tracked.toolName, tracked.toolInput, tracked.modelId, tracked.clientContext); if (fileEdit) { mapperState.cacheFileEdit(block.tool_use_id, fileEdit); } diff --git a/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts b/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts index f0463060a40..eedb2871a5d 100644 --- a/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts +++ b/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts @@ -8,6 +8,7 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { ILogService } from '../../../log/common/log.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; /** * One {@link SDKUserMessage} the queue has handed to (or is about to @@ -23,6 +24,7 @@ export interface IPendingSdkMessage { readonly sdkMessage: SDKUserMessage; readonly sdkUuid: string; readonly turnId: string; + readonly clientContext?: IAgentHostClientTelemetryContext; readonly stopWatch: StopWatch; readonly deferred: DeferredPromise; readonly steeringPendingId?: string; diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts index 085c49511d3..913c3b36175 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts @@ -10,6 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { AgentSignal } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; import { ClaudeFileEditObserver } from './claudeFileEditObserver.js'; import { ClaudeMapperState, mapSDKMessageToAgentSignals } from './claudeMapSessionEvents.js'; @@ -18,6 +19,7 @@ import type { SubagentRegistry } from './claudeSubagentRegistry.js'; interface IClaudeSdkMessageContext { readonly turnDuration?: number; readonly mode?: PermissionMode; + readonly clientContext?: IAgentHostClientTelemetryContext; } /** @@ -65,7 +67,7 @@ export class ClaudeSdkMessageRouter extends Disposable { async handle(message: SDKMessage, turnId: string | undefined, context?: IClaudeSdkMessageContext): Promise { if (message.type === 'assistant') { - this._editObserver.observeAssistant(message, context?.mode); + this._editObserver.observeAssistant(message, context?.mode, context?.clientContext); } else if (message.type === 'user' && turnId !== undefined) { await this._editObserver.observeUser(message, turnId, this._mapperState); } diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index 6ed4794e036..32655514ccb 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -13,6 +13,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { ClaudeRuntimeEffortLevel } from '../../common/claudeModelConfig.js'; import { AgentSignal } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { DeferredPromise } from '../../../../base/common/async.js'; @@ -424,7 +425,7 @@ export class ClaudeSdkPipeline extends Disposable { * If a previous turn aborted or crashed, this triggers a rebind via * the attached rematerializer before queueing. */ - async send(prompt: SDKUserMessage, turnId: string): Promise { + async send(prompt: SDKUserMessage, turnId: string, clientContext?: IAgentHostClientTelemetryContext): Promise { if (this._needsRebind) { await this._rebindQuery('recover'); } @@ -440,6 +441,7 @@ export class ClaudeSdkPipeline extends Disposable { sdkMessage: prompt, sdkUuid: typeof prompt.uuid === 'string' ? prompt.uuid : turnId, turnId, + clientContext, stopWatch: StopWatch.create(false), deferred: new DeferredPromise(), }; @@ -476,6 +478,7 @@ export class ClaudeSdkPipeline extends Disposable { sdkMessage: prompt, sdkUuid, turnId: parent.turnId, + clientContext: parent.clientContext, stopWatch: parent.stopWatch, deferred: new DeferredPromise(), steeringPendingId: pendingMessageId, @@ -673,12 +676,15 @@ export class ClaudeSdkPipeline extends Disposable { this._isResumed = true; } } - const turnId = this._queue.peekParent()?.turnId; - const turnDuration = this._queue.peekParent()?.stopWatch.elapsed(); + const parent = this._queue.peekParent(); + const turnId = parent?.turnId; + const clientContext = parent?.clientContext; + const turnDuration = parent?.stopWatch.elapsed(); try { await this._router.handle(message, turnId, { turnDuration, mode: this._currentPermissionMode, + clientContext, }); } catch (handlerErr) { this._logService.warn(`[ClaudeSdkPipeline:${this.sessionId}] router threw, skipping: ${handlerErr}`); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 194537bf400..a76508cb428 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -34,6 +34,7 @@ import { INativeEnvironmentService } from '../../../../platform/environment/comm import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; @@ -1089,13 +1090,14 @@ export class CopilotAgent extends Disposable implements IAgent { message: localize('copilotAgent.connectionClosed', "Copilot stopped unexpectedly. Retry your request."), }; for (const chat of this._allLiveSessions()) { + const clientContext = chat.currentTurnClientContext; const failedTurnId = chat.failActiveTurn(error); if (failedTurnId) { failedTurnIds.add(failedTurnId); reportCopilotClientRecoveryTurn( this._telemetryService, clientFailureId, - createCopilotFailureCorrelation(chat.sessionUri, chat.chatUri, failedTurnId, chat.sessionId), + createCopilotFailureCorrelation(chat.sessionUri, chat.chatUri, failedTurnId, chat.sessionId, clientContext), ); } } @@ -1126,7 +1128,8 @@ export class CopilotAgent extends Disposable implements IAgent { private _clientFailureCorrelation(chat: URI, turnId?: string, operationContext?: URI | IAgentChatContext): ICopilotFailureCorrelation { const context = this._resolveSendChatContext(chat, operationContext); - return createCopilotFailureCorrelation(context.configurationResource, chat, turnId, context.target?.sessionId ?? context.configurationId); + const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext?.clientTelemetryContext; + return createCopilotFailureCorrelation(context.configurationResource, chat, turnId, context.target?.sessionId ?? context.configurationId, clientTelemetryContext); } /** Number of live chats (default or peer, across all sessions) with an in-flight turn. */ @@ -2445,7 +2448,8 @@ export class CopilotAgent extends Disposable implements IAgent { const workingDirectories = Array.isArray(workingDirectoriesOrDirectory) ? workingDirectoriesOrDirectory : workingDirectoriesOrDirectory ? [workingDirectoriesOrDirectory] : undefined; const clientType = typeof clientTypeOrContext === 'string' ? clientTypeOrContext : AgentHostClientType.Unknown; const operationContext = context ?? (typeof clientTypeOrContext === 'string' ? undefined : clientTypeOrContext); - return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectories, operationContext); + const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext?.clientTelemetryContext; + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectories, operationContext, clientTelemetryContext); }, abort: (chatUri: URI, context: URI | IAgentChatContext): Promise => { return this._abortSession(chatUri, context); @@ -3065,9 +3069,9 @@ export class CopilotAgent extends Disposable implements IAgent { target?.handleClientToolCallComplete(toolCallId, result); } - private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext, clientTelemetryContext?: IAgentHostClientTelemetryContext): Promise { try { - await this._sendMessageOnce(chat, prompt, attachments, turnId, senderClientId, clientType, workingDirectories, operationContext); + await this._sendMessageOnce(chat, prompt, attachments, turnId, senderClientId, clientType, workingDirectories, operationContext, clientTelemetryContext); } catch (error) { const recovery = await this._recoverFromClosedConnection(error, 'sendMessage', this._clientFailureCorrelation(chat, turnId, operationContext)); if (turnId && recovery?.failedTurnIds.has(turnId)) { @@ -3077,7 +3081,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _sendMessageOnce(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext): Promise { + private async _sendMessageOnce(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext, clientTelemetryContext?: IAgentHostClientTelemetryContext): Promise { const context = this._resolveSendChatContext(chat, operationContext); await this._queueChat(context.configurationId, context.sequencerKey, async () => { const current = this._resolveSendChatContext(chat, operationContext); @@ -3124,7 +3128,7 @@ export class CopilotAgent extends Disposable implements IAgent { // next text/reasoning chunk (and any host-emitted announcement) // allocates a fresh response part. if (turnId) { - entry.resetTurnState(turnId, senderClientId, clientType); + entry.resetTurnState(turnId, senderClientId, clientType, clientTelemetryContext); } try { @@ -3132,7 +3136,7 @@ export class CopilotAgent extends Disposable implements IAgent { const sideChat = this._chatBackings.get(current.chatKey)?.sideChat; const turns = sideChat ? await entry.getMessages() : []; const sdkPrompt = prepareSideChatPrompt(prompt, turns, sideChat); - await entry.send(sdkPrompt, attachments, turnId, sdkMode, senderClientId, clientType, resolveAgentHostInstructions(operationContext)); + await entry.send(sdkPrompt, attachments, turnId, sdkMode, senderClientId, clientType, resolveAgentHostInstructions(operationContext), clientTelemetryContext); } catch (err) { const errCode = (err as { code?: number })?.code; const errMsg = err instanceof Error ? err.message : String(err); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e55fbb2e076..c0f14f1d5fd 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -33,6 +33,7 @@ import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, type IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; @@ -52,7 +53,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; -import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; @@ -589,9 +590,10 @@ class CopilotTurn { readonly id: string, readonly ordinal: number, readonly senderClientId: string | undefined, - readonly clientType: AgentHostClientType, + readonly clientContext: IAgentHostClientTelemetryContext, ) { } + get clientType(): AgentHostClientType { return this.clientContext.clientType; } get state(): CopilotTurnState { return this._state; } get isPending(): boolean { return this._state === 'pending'; } get isRunning(): boolean { return this._state === 'running'; } @@ -741,6 +743,7 @@ export class CopilotAgentSession extends Disposable { get chatUri(): URI { return this._chatChannelUri; } get currentTurnId(): string | undefined { return this._currentTurn?.id; } get currentTurnClientType(): AgentHostClientType { return this._currentTurn?.clientType ?? AgentHostClientType.Unknown; } + get currentTurnClientContext(): IAgentHostClientTelemetryContext | undefined { return this._currentTurn?.clientContext; } /** * Last model id seen on the SDK's per-LLM-call `Usage` event (or a * direct {@link setModel} call). We rely on the @@ -1243,10 +1246,10 @@ export class CopilotAgentSession extends Disposable { * from a previous turn so the next text/reasoning chunk allocates a new * response part. The turn becomes `running` on the first SDK event. */ - resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown): void { + resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): void { this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); - this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientType); + this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); } /** Refreshes prompt-cache state and the session-wide nano-AIU total from the SDK's authoritative usage metrics. */ @@ -1374,6 +1377,7 @@ export class CopilotAgentSession extends Disposable { } turn.toolCallDetailsReported = true; void this._telemetryReporter.toolCallDetails({ + clientContext: turn.clientContext, provider: 'copilot', session: this.resourceUri.toString(), turnId: turn.id, @@ -1399,6 +1403,7 @@ export class CopilotAgentSession extends Disposable { } const confirmKind = mapPermissionResultToConfirmKind(record?.resultKind, record?.resolvedByHook === true); this._telemetryReporter.toolApproval({ + clientContext: this._currentTurn?.clientContext, provider: 'copilot', session: this.resourceUri.toString(), turnId: this._turnId, @@ -2040,13 +2045,13 @@ export class CopilotAgentSession extends Disposable { // ---- session operations ------------------------------------------------- - async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[]): Promise { + async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[], clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): Promise { this._resetAbortToken(); if (turnId && this._currentTurn?.id !== turnId) { // Establish the `pending` turn for this message. Callers normally // call `resetTurnState` just before `send()`; this covers the // direct-send path and is a no-op when the turn already exists. - this.resetTurnState(turnId, senderClientId, clientType); + this.resetTurnState(turnId, senderClientId, clientType, clientContext); } if (this._currentTurn) { this._currentTurn.messageCharLen = prompt.length; @@ -4073,7 +4078,7 @@ export class CopilotAgentSession extends Disposable { const telemetrySession = parentToolCallId ? URI.parse(buildSubagentSessionUri(this._storageUri.toString(), parentToolCallId)) : this.resourceUri; - reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters); + reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn?.clientContext); } this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); this._reportToolApprovalIfNoPermission(e.data.toolCallId); @@ -4145,7 +4150,7 @@ export class CopilotAgentSession extends Disposable { const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; for (const filePath of filePaths) { try { - const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, this._lastSeenModelId); + const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, this._lastSeenModelId, this._currentTurn?.clientContext); if (fileEdit) { content.push(fileEdit); } @@ -4298,7 +4303,7 @@ export class CopilotAgentSession extends Disposable { if (isCopilotSdkAuthRejection(e.data)) { this._onDidRequireAuth.fire(); } - reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId)); + reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn?.clientContext)); if (this._currentTurn) { this._reportToolCallDetails(this._currentTurn, 'failed'); } @@ -4311,7 +4316,7 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onModelCallFailure(e => { - reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId)); + reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn?.clientContext)); })); // Tracks the last parent-scope usage so the async attribution enrichment @@ -5019,6 +5024,7 @@ export class CopilotAgentSession extends Disposable { if (e.agentId || (e.data.source && e.data.source.toLowerCase() !== 'user')) { return; } + const clientContext = this._currentTurn?.clientContext; void (async () => { let sources; try { @@ -5054,7 +5060,7 @@ export class CopilotAgentSession extends Disposable { } } - type AgentHostInstructionsCollectedEvent = { + type AgentHostInstructionsCollectedEvent = IAgentHostInitiatorTelemetry & { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -5064,7 +5070,7 @@ export class CopilotAgentSession extends Disposable { referencedInstructionsCount: number; claudeMdCount: number; }; - type AgentHostInstructionsCollectedClassification = { + type AgentHostInstructionsCollectedClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that emitted this event (e.g. copilotcli). Absent on local rows; use presence to distinguish AH from local.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier. Absent on local rows.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the emission was from a subagent session.' }; @@ -5077,6 +5083,7 @@ export class CopilotAgentSession extends Disposable { comment: 'Agent Host emission of agentHost.instructionsCollected. Carries the subset of the local shape that can be honestly (or close-analogously) computed from the SDK\'s InstructionSource list; other fields are intentionally omitted (see source comment).'; }; this._telemetryService.publicLog2('agentHost.instructionsCollected', { + ...toInitiatorTelemetry(clientContext), provider: this.resourceUri.scheme, agentSessionId: AgentSession.id(this.resourceUri), isSubagentSession: isSubagentSession(this.resourceUri), diff --git a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts index 22c1ac4a4b1..f4a4af5c8a4 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts @@ -9,21 +9,23 @@ import type { URI } from '../../../../base/common/uri.js'; import { packErrorForTelemetry } from '../../../telemetry/common/errorTelemetry.js'; import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; +import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; export type CopilotClientFailureOperation = 'abort' | 'changeAgent' | 'changeModel' | 'getSessionMetadata' | 'listSessions' | 'modelRefresh' | 'sendMessage' | 'startClient'; export type CopilotClientFailureKind = 'clientNotConnected' | 'connectionClosed' | 'connectionDisposed' | 'runtimeConnectionClosed' | 'startupFailed'; type CopilotStartupFailureCause = 'nativeModuleProcedureNotFound' | 'nativeModuleInitializationFailed' | 'nativeModuleNotFound' | 'permissionDenied' | 'timeout' | 'spawnFailed' | 'processExitedUnexpectedly' | 'processExited'; type CopilotStartupFailureResource = 'runtime' | 'cliNative' | 'conpty' | 'sandbox' | 'other'; -export interface ICopilotFailureCorrelation { +export interface ICopilotFailureCorrelation extends IAgentHostInitiatorTelemetry { readonly agentSessionId?: string; readonly chatSessionId?: string; readonly turnId?: string; readonly sdkSessionId?: string; } -type CopilotSessionFailureCorrelation = { +type CopilotSessionFailureCorrelation = IAgentHostInitiatorTelemetry & { readonly agentSessionId: string; readonly chatSessionId: string; readonly turnId: string | undefined; @@ -62,8 +64,9 @@ export function normalizeCopilotApiEndpoint(endpoint: string | undefined): Copil return 'other'; } -export function createCopilotFailureCorrelation(sessionUri: URI, chatUri: URI, turnId: string | undefined, sdkSessionId: string): CopilotSessionFailureCorrelation { +export function createCopilotFailureCorrelation(sessionUri: URI, chatUri: URI, turnId: string | undefined, sdkSessionId: string, clientContext?: IAgentHostClientTelemetryContext): CopilotSessionFailureCorrelation { return { + ...toInitiatorTelemetry(clientContext), agentSessionId: AgentSession.id(sessionUri), chatSessionId: getTelemetryChatSessionId(chatUri), turnId: turnId || undefined, @@ -108,7 +111,7 @@ type CopilotClientFailureEvent = ICopilotFailureCorrelation & { callstack: string | undefined; }; -type CopilotClientFailureClassification = { +type CopilotClientFailureClassification = IAgentHostInitiatorClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by detections and recovery telemetry for one Copilot client failure episode.' }; failureKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded category of Copilot client failure that was detected.' }; operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Copilot provider operation that detected the client failure.' }; @@ -242,7 +245,7 @@ type CopilotClientRecoveryTurnEvent = CopilotSessionFailureCorrelation & { clientFailureId: string; }; -type CopilotClientRecoveryTurnClassification = { +type CopilotClientRecoveryTurnClassification = IAgentHostInitiatorClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by all telemetry for one Copilot client failure episode.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; @@ -273,7 +276,7 @@ type CopilotSdkSessionErrorEvent = CopilotSessionFailureCorrelation & { callstack: string | undefined; }; -type CopilotSdkSessionErrorClassification = { +type CopilotSdkSessionErrorClassification = IAgentHostInitiatorClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; @@ -337,7 +340,7 @@ type CopilotModelCallFailureEvent = CopilotSessionFailureCorrelation & { imagePartsMissingMediaType: number | undefined; }; -type CopilotModelCallFailureClassification = { +type CopilotModelCallFailureClassification = IAgentHostInitiatorClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts index 8b455a24382..27194fc4e30 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts @@ -6,12 +6,14 @@ import { URI } from '../../../../base/common/uri.js'; import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentSession } from '../../common/state/sessionState.js'; +import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; type TodoStoreOperation = 'read' | 'write' | 'mixed'; type TodoStoreTarget = 'todos' | 'todo_deps' | 'both'; -type TodoStoreOperationEvent = { +type TodoStoreOperationEvent = IAgentHostInitiatorTelemetry & { operation: TodoStoreOperation; target: TodoStoreTarget; toolCallId: string; @@ -20,7 +22,7 @@ type TodoStoreOperationEvent = { isSubagentSession: boolean; }; -type TodoStoreOperationClassification = { +type TodoStoreOperationClassification = IAgentHostInitiatorClassification & { operation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation read from, wrote to, or both read from and wrote to todo storage.' }; target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation referenced todo items, todo dependencies, or both.' }; toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the SQL tool call, used to correlate with generic tool telemetry.' }; @@ -41,13 +43,14 @@ interface ISqlToken { readonly kind: 'identifier' | 'punctuation'; } -export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly> | undefined): void { +export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly> | undefined, clientContext?: IAgentHostClientTelemetryContext): void { const operation = getCopilotTodoStoreOperationData(toolName, toolInput); if (!operation) { return; } telemetryService.publicLog2('todoStoreOperation', { + ...toInitiatorTelemetry(clientContext), ...operation, toolCallId, provider: session.scheme, diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 221e9cfc7da..79c8593079a 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -15,7 +15,7 @@ import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; -import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentSession, type IAgentCreateChatOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; @@ -1182,11 +1182,15 @@ export class ProtocolServerHandler extends Disposable { private _createClientTelemetryContext(clientInfo: Implementation | undefined, meta: Record | undefined, transport: IProtocolTransport, fallbackConnectionKind = AgentHostClientConnectionKind.Unknown): IAgentHostClientTelemetryContext { const connectionKind = readClientConnectionKind(meta); + const machineId = readClientMachineId(meta); + const devDeviceId = readClientDevDeviceId(meta); return { clientType: getAgentHostClientType(clientInfo), connectionKind: connectionKind === AgentHostClientConnectionKind.Unknown ? fallbackConnectionKind : connectionKind, transportKind: transport.transportKind ?? AgentHostTransportKind.Unknown, hostLaunchKind: this._config.hostLaunchKind ?? AgentHostLaunchKind.Unknown, + ...(machineId ? { machineId } : {}), + ...(devDeviceId ? { devDeviceId } : {}), }; } diff --git a/src/vs/platform/agentHost/node/shared/editArcReporter.ts b/src/vs/platform/agentHost/node/shared/editArcReporter.ts index dfee8c4e983..bca064fa86d 100644 --- a/src/vs/platform/agentHost/node/shared/editArcReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editArcReporter.ts @@ -16,14 +16,17 @@ import { ILogService } from '../../../log/common/log.js'; import { IEditArcTelemetryClassification, IEditArcTelemetryEvent } from '../../../telemetry/common/editArcTelemetry.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostEditTelemetryEnabledConfigKey, platformRootSchema } from '../../common/agentHostSchema.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTelemetryService, isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; +import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; export interface IEditArcReporterLaunchParams { + readonly clientContext?: IAgentHostClientTelemetryContext; readonly sessionUri: string; readonly turnId: string; readonly toolCallId: string; @@ -313,7 +316,8 @@ class EditArcReporter extends Disposable { const provider = AgentSession.provider(sessionUri) ?? 'unknown'; const originalLineCounts = new EditArcTracker(this._params.beforeText, this._params.initialEdit).getLineCountInfo(); const currentLineCounts = this._tracker.getLineCountInfo(); - const event: IEditArcTelemetryEvent = { + const event: IEditArcTelemetryEvent & IAgentHostInitiatorTelemetry = { + ...toInitiatorTelemetry(this._params.clientContext), sourceKeyCleaned: 'source:Chat.applyEdits', extensionId: undefined, extensionVersion: undefined, @@ -336,9 +340,25 @@ class EditArcReporter extends Disposable { currentLineCount: currentLineCounts.insertedLineCounts, currentDeletedLineCount: currentLineCounts.deletedLineCounts, }; - this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); + this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); if (provider === 'copilotcli' && isAgentHostTelemetryService(this._telemetryService)) { - const { didBranchChange, timeDelayMs: delay, originalCharCount, originalLineCount, originalDeletedLineCount, arc, currentLineCount, currentDeletedLineCount, ...properties } = event; + const { + didBranchChange, + timeDelayMs: delay, + originalCharCount, + originalLineCount, + originalDeletedLineCount, + arc, + currentLineCount, + currentDeletedLineCount, + initiatorClientType: _, + initiatorConnectionKind: _2, + initiatorTransportKind: _3, + hostLaunchKind: _4, + initiatorMachineId: _5, + initiatorDevDeviceId: _6, + ...properties + } = event; const telemetry = this._telemetryService as IAgentHostTelemetryService; telemetry.sendGHTelemetryEvent('vscode.editTelemetry.reportEditArc', withoutUndefined(properties), { didBranchChange, diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index 504c52cb941..aa93350af89 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -12,7 +12,9 @@ import { createDecorator } from '../../../instantiation/common/instantiation.js' import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isAhpChatChannel, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; +import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './editSurvivalTracker.js'; /** @@ -28,6 +30,7 @@ import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './edit * revisit when we have a notebook-aware tracker. */ export interface IEditSurvivalReporterLaunchParams { + readonly clientContext?: IAgentHostClientTelemetryContext; /** Full session URI string (e.g. `claude:/abc123`). */ readonly sessionUri: string; readonly turnId: string; @@ -81,7 +84,7 @@ export class NullEditSurvivalReporterFactory implements IEditSurvivalReporterFac } } -interface IEditSurvivalTelemetryEvent { +interface IEditSurvivalTelemetryEvent extends IAgentHostInitiatorTelemetry { provider: string; modelId: string; toolName: string; @@ -102,7 +105,7 @@ interface IEditSurvivalTelemetryEvent { currentTextLength: number; } -type IEditSurvivalTelemetryClassification = { +type IEditSurvivalTelemetryClassification = IAgentHostInitiatorClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model that produced the edit, e.g. "claude-sonnet-4.5" or "gpt-5-mini". Empty if the host could not determine the per-edit model.' }; toolName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Name of the edit tool that produced the edit, e.g. "Edit", "apply_patch". Empty if unknown.' }; @@ -200,6 +203,7 @@ class SessionEditSurvivalReporter extends Disposable { this._telemetryService.publicLog2( 'agentHost.trackEditSurvival', { + ...toInitiatorTelemetry(this._params.clientContext), provider: AgentSession.provider(sessionUri) ?? 'unknown', modelId: this._params.modelId ?? '', toolName: this._params.toolName ?? '', diff --git a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts index caa8ff65602..0665875739b 100644 --- a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts @@ -12,6 +12,7 @@ import { AttributedToolResultFileEditContent, FILE_EDIT_ATTRIBUTION_PROPERTY, IA import { ISessionDatabase } from '../../common/sessionDataService.js'; import { buildSessionDbUri } from '../../common/sessionDbUri.js'; import { FileEditKind, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; +import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { extractAiChunks } from './editChunkExtractor.js'; import { IEditSurvivalReporterFactory } from './editSurvivalReporter.js'; import { IEditArcReporterService } from './editArcReporter.js'; @@ -108,7 +109,7 @@ export class FileEditTracker { * for region-based survival scoring; unknown shapes fall back to * whole-file scoring. */ - async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string, toolName: string, toolInput: unknown, modelId: string | undefined): Promise { + async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string, toolName: string, toolInput: unknown, modelId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): Promise { const edit = this._completedEdits.get(filePath); if (!edit) { return undefined; @@ -155,6 +156,7 @@ export class FileEditTracker { } this._editSurvivalReporterFactory.launch({ + clientContext, sessionUri: this._sessionUri, turnId, toolCallId, @@ -199,6 +201,7 @@ export class FileEditTracker { const initialEdit = extractArcTextEdit(toolName, toolInput, beforeText, afterText) ?? createArcTextEditFromDiff(changes, beforeText, afterText); this._editArcReporterService.reportEdit({ + clientContext, sessionUri: this._sessionUri, turnId, toolCallId, 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 4c1fb7afdf5..5d5b1de8187 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -29,7 +29,8 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { buildDefaultChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; import { AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../common/agentHostManagedSettings.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; @@ -68,6 +69,23 @@ import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.j type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; +class TestClientIdentityTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sessionId = 'client-session-id'; + readonly machineId = 'client-machine-id'; + readonly sqmId = 'client-sqm-id'; + readonly devDeviceId = 'client-dev-device-id'; + readonly firstSessionDate = '2026-08-14'; + readonly sendErrorTelemetry = true; + publicLog(): void { } + publicLog2(): void { } + publicLogError(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } +} + interface ITestRootConfigNotificationParams { readonly action?: { readonly type?: string; @@ -269,8 +287,8 @@ suite('RemoteAgentHostProtocolClient', () => { }; } - function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { - const client = disposables.add(new RemoteAgentHostProtocolClient(identity, transport, loadEstimator, clientId, clientInfo, logService, permissionService, configurationService)); + function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + const client = disposables.add(new RemoteAgentHostProtocolClient(identity, transport, loadEstimator, clientId, clientInfo, logService, permissionService, configurationService, telemetryService)); return { client, transport, configurationService }; } @@ -292,6 +310,38 @@ suite('RemoteAgentHostProtocolClient', () => { await connectPromise; } + test('initialize sends the local client telemetry identity only for usage telemetry', async () => { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.RemoteExtensionHost)); + const { client } = createClientForIdentity('test.example:1234', transport, createPermissionService(), undefined, new NullLogService(), new TestConfigurationService(), undefined, agentsWindowAgentHostClientInfo, new TestClientIdentityTelemetryService()); + const connectPromise = client.connect(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + + assert.deepStrictEqual((initialize.params as { _meta?: Record })._meta, { + 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }); + + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + await connectPromise; + + const noTelemetryTransport = disposables.add(new TestProtocolTransport()); + const noTelemetryClient = createClient(noTelemetryTransport).client; + const noTelemetryConnectPromise = noTelemetryClient.connect(); + const noTelemetryInitialize = noTelemetryTransport.sentMessages[0] as JsonRpcRequest; + assert.strictEqual((noTelemetryInitialize.params as { _meta?: Record })._meta, undefined); + noTelemetryTransport.fireMessage({ + jsonrpc: '2.0', + id: noTelemetryInitialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + await noTelemetryConnectPromise; + }); + async function flushMicrotasks(): Promise { // `await Promise.resolve()` only advances one microtask; loop to drain chained handlers. for (let i = 0; i < 10; i++) { @@ -1764,7 +1814,7 @@ suite('RemoteAgentHostProtocolClient', () => { * client plus a `transports` array recording each transport handed * out, so tests can drive handshake/reconnect interactions. */ - function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation): { client: RemoteAgentHostProtocolClient; transports: TestClientProtocolTransport[] } { + function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: RemoteAgentHostProtocolClient; transports: TestClientProtocolTransport[] } { const transports: TestClientProtocolTransport[] = []; const factory = () => { const t = disposables.add(new TestClientProtocolTransport()); @@ -1772,7 +1822,7 @@ suite('RemoteAgentHostProtocolClient', () => { return t; }; const client = disposables.add(new RemoteAgentHostProtocolClient( - 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, new TestConfigurationService(), + 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, )); return { client, transports }; } @@ -1939,7 +1989,7 @@ suite('RemoteAgentHostProtocolClient', () => { test('falls back to initialize with client info when the server forgot the client', async function () { this.timeout(10_000); - const { client, transports } = createFactoryClient(createPermissionService(), agentsWindowAgentHostClientInfo); + const { client, transports } = createFactoryClient(createPermissionService(), agentsWindowAgentHostClientInfo, new TestClientIdentityTelemetryService()); let connectedRequest = Disposable.None; try { const connectPromise = client.connect(); @@ -1953,6 +2003,10 @@ suite('RemoteAgentHostProtocolClient', () => { const reconnectTransport = await waitForTransport(transports, 1); reconnectTransport.connectDeferred.complete(); const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + assert.deepStrictEqual((reconnect.params as { _meta?: Record })._meta, { + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }); reconnectTransport.fireMessage({ jsonrpc: '2.0', id: reconnect.id, @@ -1960,7 +2014,16 @@ suite('RemoteAgentHostProtocolClient', () => { }); const initialize = await waitForRequest(reconnectTransport, 'initialize'); - assert.deepStrictEqual((initialize.params as { clientInfo?: Implementation }).clientInfo, agentsWindowAgentHostClientInfo); + assert.deepStrictEqual({ + clientInfo: (initialize.params as { clientInfo?: Implementation }).clientInfo, + meta: (initialize.params as { _meta?: Record })._meta, + }, { + clientInfo: agentsWindowAgentHostClientInfo, + meta: { + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }, + }); reconnectTransport.fireMessage({ jsonrpc: '2.0', id: initialize.id, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index b7ba37b5d6b..57d2732b41b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -11,6 +11,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; @@ -2001,13 +2003,26 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { subscriptions: [buildTurnChangesetUri(sessionStr, 'turn-1')], }); - svc.onTurnComplete(sessionStr, 'turn-1'); + svc.onTurnComplete(sessionStr, 'turn-1', { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); const data = await waitForTelemetry(telemetry, 'agentHost.changesetComputed', d => d.kind === 'turn'); assert.deepStrictEqual({ provider: data.provider, agentSessionId: data.agentSessionId, turnId: data.turnId, + initiatorClientType: data.initiatorClientType, + initiatorConnectionKind: data.initiatorConnectionKind, + initiatorTransportKind: data.initiatorTransportKind, + hostLaunchKind: data.hostLaunchKind, + initiatorMachineId: data.initiatorMachineId, + initiatorDevDeviceId: data.initiatorDevDeviceId, kind: data.kind, outcome: data.outcome, isMultiRoot: data.isMultiRoot, @@ -2018,6 +2033,12 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { provider: URI.parse(sessionStr).scheme, agentSessionId: AgentSession.id(sessionStr), turnId: 'turn-1', + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', kind: 'turn', outcome: 'computed', isMultiRoot: false, diff --git a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts index 8ee13c3e997..88862cd427c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts @@ -7,6 +7,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { ActionType, type ChatInputCompletedAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, type ChatInputAnswer, type ChatInputRequest, type ChatState } from '../../common/state/sessionState.js'; import { AgentHostInputRequestTracker } from '../../node/agentHostInputRequestTracker.js'; @@ -40,11 +42,11 @@ suite('AgentHostInputRequestTracker', () => { const rootChat = buildDefaultChatUri(rootSession); const subagentChat = buildSubagentChatUri(rootSession, 'subagent-tool'); - function createTracker(): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } { + function createTracker(clientContext?: IAgentHostClientTelemetryContext): { telemetry: CapturingTelemetryService; tracker: AgentHostInputRequestTracker } { const telemetry = new CapturingTelemetryService(); return { telemetry, - tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 })), + tracker: new AgentHostInputRequestTracker(new AgentHostTelemetryReporter(telemetry), () => ({ elapsed: () => 25 }), () => clientContext), }; } @@ -75,7 +77,14 @@ suite('AgentHostInputRequestTracker', () => { } test('emits accepted metrics from reduced state with standard root identifiers', () => { - const { telemetry, tracker } = createTracker(); + const { telemetry, tracker } = createTracker({ + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); const request: ChatInputRequest = { id: 'request-1', purpose: ChatInputRequestPurpose.AskUser, @@ -104,6 +113,12 @@ suite('AgentHostInputRequestTracker', () => { assert.deepStrictEqual(telemetry.events.map(event => ({ eventName: event.eventName, data: event.data })), [{ eventName: 'askQuestionsToolInvoked', data: { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', requestId: 'turn-1', questionCount: 7, answeredCount: 5, diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index a259707bdc8..e32af76be51 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -95,6 +95,52 @@ suite('AgentHostTelemetryReporter', () => { }]); }); + test('userMessageSent includes only provided initiating client telemetry identity', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.userMessageSent('copilot', 'client-1', { + ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }, session, 'turn-1', undefined, 'direct', undefined); + reporter.userMessageSent('copilot', 'client-2', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), session, 'turn-2', undefined, 'direct', undefined); + + assert.deepStrictEqual(service.standardEvents.map(event => ({ + initiatorMachineId: event.data?.initiatorMachineId, + initiatorDevDeviceId: event.data?.initiatorDevDeviceId, + })), [{ + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }, { + initiatorMachineId: undefined, + initiatorDevDeviceId: undefined, + }]); + }); + + test('executionModeChanged attributes a client-originated mode change', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.executionModeChanged('copilot', session, 'interactive', 'plan', 2, { + ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); + + assert.deepStrictEqual(service.standardEvents.map(event => ({ + eventName: event.eventName, + initiatorClientType: event.data?.initiatorClientType, + initiatorMachineId: event.data?.initiatorMachineId, + initiatorDevDeviceId: event.data?.initiatorDevDeviceId, + })), [{ + eventName: 'agentHost.executionModeChanged', + initiatorClientType: 'editor_window', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }]); + }); + test('assistantMessageReceived emits request.options.tools keyed on the client request id, and no-ops without one or without tools', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); @@ -170,6 +216,7 @@ suite('AgentHostTelemetryReporter', () => { }); // dropped: no tools were available await reporter.toolCallDetails({ provider: 'copilot', session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', clientType: AgentHostClientType.EditorWindow, model: 'gpt-x', responseType: 'success', + clientContext: { ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), machineId: 'client-machine-id', devDeviceId: 'client-dev-device-id' }, toolCounts: {}, availableTools: ['grep', 'edit'], turnIndex: 2, turnDuration: 1200, messageCharLen: 11, numRequests: 1, totalToolCalls: 0, parallelToolCallRounds: 0, parallelToolCallsTotal: 0, @@ -184,6 +231,9 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.standardEvents, [{ eventName: 'toolCallDetails', data: { + initiatorClientType: 'editor_window', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', provider: 'copilot', agentSessionId: AgentSession.id(session), isSubagentSession: false, @@ -266,6 +316,7 @@ suite('AgentHostTelemetryReporter', () => { }); reporter.toolApproval({ provider: 'copilot', session, turnId: 'turn-2', + clientContext: { ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), machineId: 'client-machine-id', devDeviceId: 'client-dev-device-id' }, toolId: 'bash', toolSourceKind: 'internal', confirmKind: 'userAction', confirmationNotNeededReason: undefined, @@ -301,6 +352,9 @@ suite('AgentHostTelemetryReporter', () => { }, { eventName: 'chat.toolApproval', data: { + initiatorClientType: 'editor_window', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', provider: 'copilot', agentSessionId: AgentSession.id(session), isSubagentSession: false, diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 00784d78272..348db62d58e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -17,6 +17,8 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../telemetry/common/telemetryUtils.js'; import { AgentSession, IAgent } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; @@ -111,7 +113,7 @@ suite('AgentSideEffects — tool call telemetry', () => { stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); } - function startTurn(turnId: string, text = 'hello', modelId?: string): void { + function startTurn(turnId: string, text = 'hello', modelId?: string, clientContext?: IAgentHostClientTelemetryContext): void { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId, @@ -119,7 +121,7 @@ suite('AgentSideEffects — tool call telemetry', () => { message: { text, origin: { kind: MessageKind.User }, model: modelId ? { id: modelId } : undefined }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(defaultChatUri, action); + sideEffects.handleAction(defaultChatUri, action, 'test', clientContext); } function fire(action: ChatAction): void { @@ -263,6 +265,39 @@ suite('AgentSideEffects — tool call telemetry', () => { }]); }); + test('attributes tool telemetry to the initiating turn client', () => { + setupSession(); + const clientContext: IAgentHostClientTelemetryContext = { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }; + startTurn('turn-client', 'hello', 'model-a', clientContext); + toolStart('turn-client', 'tool-client', 'grep'); + toolComplete('turn-client', 'tool-client', { success: true, pastTenseMessage: 'searched' }); + completeTurn('turn-client'); + + const event = toolEvents()[0]; + assert.deepStrictEqual({ + initiatorClientType: event.data.initiatorClientType, + initiatorConnectionKind: event.data.initiatorConnectionKind, + initiatorTransportKind: event.data.initiatorTransportKind, + hostLaunchKind: event.data.hostLaunchKind, + initiatorMachineId: event.data.initiatorMachineId, + initiatorDevDeviceId: event.data.initiatorDevDeviceId, + }, { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }); + }); + test('emits userCancelled with mcp source kind for a denied mcp tool', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 640e7891a2e..133676062b5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -17,6 +17,8 @@ import { TelemetryTrustedValue } from '../../../telemetry/common/telemetryUtils. import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, IAgent } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import type { SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; @@ -138,7 +140,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); } - function startTurn(turnId: string, text = 'hello', modelId?: string, chatUri = defaultChatUri): void { + function startTurn(turnId: string, text = 'hello', modelId?: string, chatUri = defaultChatUri, clientContext?: IAgentHostClientTelemetryContext): void { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId, @@ -150,7 +152,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { // invoke `handleAction` so the side-effect (which calls // `agent.sendMessage` and `turnTracker.turnStarted`) runs. stateManager.dispatchClientAction(chatUri, action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(chatUri, action); + sideEffects.handleAction(chatUri, action, 'test', clientContext); } function fire(action: ChatAction, chatUri = defaultChatUri): void { @@ -247,6 +249,49 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.strictEqual(data.folderCount, 0); }); + test('attributes completed and failed turns to the initiating client identity', () => { + setupSession(); + const clientContext: IAgentHostClientTelemetryContext = { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }; + startTurn('t-client', 'hello', undefined, defaultChatUri, clientContext); + fire({ type: ActionType.ChatError, turnId: 't-client', duration: 100, error: { errorType: 'providerFailed', message: 'failed' } }); + + assert.deepStrictEqual([completedEvents()[0], failedEvents()[0]].map(event => { + const data = event.data as Record; + return { + eventName: event.eventName, + initiatorClientType: data.initiatorClientType, + initiatorConnectionKind: data.initiatorConnectionKind, + initiatorTransportKind: data.initiatorTransportKind, + hostLaunchKind: data.hostLaunchKind, + initiatorMachineId: data.initiatorMachineId, + initiatorDevDeviceId: data.initiatorDevDeviceId, + }; + }), [{ + eventName: 'agentHost.turnCompleted', + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }, { + eventName: 'agentHost.turnFailed', + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }]); + }); + test('emits turnCompleted with the multi-root working-directory shape', () => { setupSession(true, ['file:///work/app', 'file:///work/api']); startTurn('turn-mr', 'hello'); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 2c293b6bbe8..17ea4d9e462 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -2180,15 +2180,30 @@ suite('AgentSideEffects', () => { test('calls abortSession on the agent', async () => { setupSession(); + const clientContext = { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }; sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000, - }); + }, 'client-1', clientContext); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]); + const abortContext = agent.chatContexts.find(call => call.boundary === 'abort')?.context; + assert.deepStrictEqual({ + abortSessionCalls: agent.abortSessionCalls, + clientTelemetryContext: !URI.isUri(abortContext) ? abortContext?.clientTelemetryContext : undefined, + }, { + abortSessionCalls: [URI.parse(sessionUri.toString())], + clientTelemetryContext: clientContext, + }); }); }); @@ -2198,16 +2213,37 @@ suite('AgentSideEffects', () => { test('calls changeModel on the agent before sending the message', async () => { setupSession(); + const clientContext = { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }; sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, - }); + }, 'client-1', clientContext); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }]); + const contexts = Object.fromEntries(agent.chatContexts + .filter(call => call.boundary === 'changeModel' || call.boundary === 'changeAgent' || call.boundary === 'sendMessage') + .map(call => [call.boundary, !URI.isUri(call.context) ? call.context?.clientTelemetryContext : undefined])); + assert.deepStrictEqual({ + changeModelCalls: agent.changeModelCalls, + contexts, + }, { + changeModelCalls: [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }], + contexts: { + changeModel: clientContext, + changeAgent: clientContext, + sendMessage: clientContext, + }, + }); }); test('waits for model selection before sending the message', async () => { @@ -5012,7 +5048,14 @@ suite('AgentSideEffects', () => { stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.SessionConfigChanged, config: { mode: 'plan' }, - }, { clientId: 'test-client', clientSeq: 1 }); + }, { clientId: 'test-client', clientSeq: 1 }, { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionConfigChanged, config: { mode: 'plan' }, @@ -5031,6 +5074,12 @@ suite('AgentSideEffects', () => { eventName: 'agentHost.executionModeChanged', data: { provider: 'mock', + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', agentSessionId: 'session-1', isSubagentSession: false, previousMode: 'interactive', @@ -5066,6 +5115,54 @@ suite('AgentSideEffects', () => { suite('subagent sessions', () => { + test('inherits the parent turn client identity for subagent telemetry', () => { + setupSession(); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-client', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action, 'test', { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: 'tc-client', + agentName: 'reviewer', + agentDisplayName: 'Reviewer', + }); + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-client'); + const subagentTurnId = stateManager.getActiveTurnId(subagentUri); + assert.ok(subagentTurnId); + agent.fireProgress({ kind: 'action', resource: URI.parse(subagentUri), action: { type: ActionType.ChatTurnComplete, turnId: subagentTurnId, duration: 1 } }); + + const event = telemetryService.events.find(event => event.eventName === 'agentHost.turnCompleted' && (event.data as Record).isSubagentSession === true); + assert.deepStrictEqual({ + initiatorClientType: (event?.data as Record | undefined)?.initiatorClientType, + initiatorConnectionKind: (event?.data as Record | undefined)?.initiatorConnectionKind, + initiatorTransportKind: (event?.data as Record | undefined)?.initiatorTransportKind, + hostLaunchKind: (event?.data as Record | undefined)?.hostLaunchKind, + initiatorMachineId: (event?.data as Record | undefined)?.initiatorMachineId, + initiatorDevDeviceId: (event?.data as Record | undefined)?.initiatorDevDeviceId, + }, { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', + }); + }); + test('subagent_started creates a subagent chat and dispatches content on parent tool call', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/claudeFileEditObserver.test.ts b/src/vs/platform/agentHost/test/node/claudeFileEditObserver.test.ts index 07156d8003a..195fabd0cf9 100644 --- a/src/vs/platform/agentHost/test/node/claudeFileEditObserver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeFileEditObserver.test.ts @@ -18,6 +18,8 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { IAgentEditAttributionService, NullAgentEditAttributionService } from '../../common/fileEditAttribution.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; import { ToolResultContentType } from '../../common/state/sessionState.js'; @@ -82,9 +84,17 @@ suite('ClaudeFileEditObserver', () => { const { observer, fileService, mapperState, arcReports } = createObserver(disposables); await fileService.writeFile(URI.file('/work/a.txt'), VSBuffer.fromString('before')); + const clientContext = { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }; observer.observeAssistant(assistantMessage([ { type: 'tool_use', id: 'tu-1', name: 'Write', input: { file_path: '/work/a.txt', content: 'after' } }, - ]), 'plan'); + ]), 'plan', clientContext); // Tool runs (we simulate it here): file content changes. await fileService.writeFile(URI.file('/work/a.txt'), VSBuffer.fromString('after')); @@ -97,9 +107,11 @@ suite('ClaudeFileEditObserver', () => { assert.deepStrictEqual({ cachedType: cached?.type, arcMode: arcReports[0]?.mode, + clientContext: arcReports[0]?.clientContext, }, { cachedType: ToolResultContentType.FileEdit, arcMode: 'plan', + clientContext, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5b77d568a41..272450ff57f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -40,6 +40,8 @@ import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilo import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentChatContext, type IAgentChatMetadata, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentMaterializeChatEvent, type IAgentSpawnChatEvent } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -2345,7 +2347,17 @@ suite('CopilotAgent', () => { dispose: () => { }, }); try { - await assert.rejects(agent.chats.abort(chat, exactChatContext(AgentSession.uri('copilotcli', 'abort-failure'), chat)), /Client not connected/); + await assert.rejects(agent.chats.abort(chat, { + ...exactChatContext(AgentSession.uri('copilotcli', 'abort-failure'), chat), + clientTelemetryContext: { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }, + }), /Client not connected/); const failure = telemetryService.errorEvents[0].data as Record; assert.deepStrictEqual({ discardCount, @@ -2362,6 +2374,12 @@ suite('CopilotAgent', () => { clientFailureId: 'string', failureKind: 'clientNotConnected', operation: 'abort', + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', agentSessionId: 'abort-failure', chatSessionId: getTelemetryChatSessionId(chat), turnId: undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 45939e82578..60002591224 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -27,6 +27,7 @@ import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUt import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; @@ -4930,10 +4931,18 @@ suite('CopilotAgentSession', () => { test('emits todo store telemetry for successful built-in Copilot SQL', async () => { const telemetryService = new CapturingTelemetryService(); - const { mockSession, waitForSignal } = await createAgentSession(disposables, { + const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { telemetryService, sessionUri: AgentSession.uri('copilotcli', 'test-session-1'), }); + session.resetTurnState('turn-sql', undefined, AgentHostClientType.EditorWindow, { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); mockSession.fire('tool.execution_start', { toolCallId: 'tc-sql', @@ -4949,6 +4958,12 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'todoStoreOperation'), [{ eventName: 'todoStoreOperation', data: { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', operation: 'write', target: 'todos', toolCallId: 'tc-sql', @@ -10015,7 +10030,15 @@ suite('CopilotAgentSession', () => { test('emits with counts derived from source types + AH identifiers', async () => { const telemetryService = new CapturingTelemetryService(); - const { mockSession } = await createAgentSession(disposables, { telemetryService }); + const { session, mockSession } = await createAgentSession(disposables, { telemetryService }); + session.resetTurnState('turn-instructions', undefined, AgentHostClientType.EditorWindow, { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }); mockSession.getInstructionSourcesResult = { sources: [ @@ -10036,6 +10059,12 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(emitted, [{ eventName: 'agentHost.instructionsCollected', data: { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', provider: 'copilot', agentSessionId: 'test-session-1', isSubagentSession: false, diff --git a/src/vs/platform/agentHost/test/node/copilotFailureTelemetry.test.ts b/src/vs/platform/agentHost/test/node/copilotFailureTelemetry.test.ts index 923dcabd122..eff51699a4b 100644 --- a/src/vs/platform/agentHost/test/node/copilotFailureTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotFailureTelemetry.test.ts @@ -10,6 +10,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { readAgentErrorTelemetryMeta } from '../../common/meta/agentErrorMeta.js'; import { buildChatUri, buildSubagentSessionUri } from '../../common/state/sessionState.js'; import { classifyCopilotClientFailure, createCopilotFailureCorrelation, normalizeCopilotApiEndpoint, reportCopilotModelCallFailure } from '../../node/copilot/copilotFailureTelemetry.js'; @@ -66,7 +68,20 @@ suite('CopilotFailureTelemetry', () => { const session = AgentSession.uri('copilotcli', 'agent-session-id'); const chat = URI.parse(buildChatUri(session, 'peer-chat-id')); - assert.deepStrictEqual(createCopilotFailureCorrelation(session, chat, 'turn-id', 'sdk-session-id'), { + assert.deepStrictEqual(createCopilotFailureCorrelation(session, chat, 'turn-id', 'sdk-session-id', { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }), { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', agentSessionId: 'agent-session-id', chatSessionId: getTelemetryChatSessionId(chat), turnId: 'turn-id', diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 82c6449f3a2..bc8db48c3e9 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -358,14 +358,17 @@ export class MockAgent implements IAgent { return this.sendMessage(session, chat, prompt, attachments, turnId, senderClientId, clientType); }, abort: (chat: URI, context: URI | IAgentChatContext): Promise => { + this._recordContext('abort', chat, context); const { session } = this._resolveChatTarget(chat, context); return this.abortSession(session); }, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { + this._recordContext('changeModel', chatUri, context); const { session, chat } = this._resolveChatTarget(chatUri, context); return this.changeModel(session, model, chat); }, changeAgent: (chatUri: URI, agent: AgentSelection | undefined, context: URI | IAgentChatContext): Promise => { + this._recordContext('changeAgent', chatUri, context); const { session, chat } = this._resolveChatTarget(chatUri, context); return this.changeAgent(session, agent, chat); }, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 598f0018e06..f03d8b67a7a 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -1250,6 +1250,8 @@ suite('ProtocolServerHandler', () => { test('retains client info for action attribution across reconnect', async () => { const transport1 = connectClient('client-attribution', undefined, agentsWindowAgentHostClientInfo, { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.DevTunnel, + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', }); transport1.simulateMessage(notification('dispatchAction', { channel: 'ahp-root://', @@ -1265,6 +1267,10 @@ suite('ProtocolServerHandler', () => { clientId: 'client-attribution', lastSeenServerSeq: stateManager.serverSeq, subscriptions: [], + _meta: { + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }, })); await reconnectRespPromise; transport2.simulateMessage(notification('dispatchAction', { @@ -1276,12 +1282,81 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual({ clientTypes: agentService.handledClientTypes, connectionKinds: agentService.handledClientContexts.map(context => context?.connectionKind), + machineIds: agentService.handledClientContexts.map(context => context?.machineId), + devDeviceIds: agentService.handledClientContexts.map(context => context?.devDeviceId), }, { clientTypes: ['agents_window', 'agents_window'], connectionKinds: ['dev_tunnel', 'dev_tunnel'], + machineIds: ['client-machine-id', 'client-machine-id'], + devDeviceIds: ['client-dev-device-id', 'client-dev-device-id'], }); }); + test('does not retain client telemetry identity when reconnect omits it', async () => { + const transport1 = connectClient('client-consent', undefined, agentsWindowAgentHostClientInfo, { + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }); + transport1.simulateClose(); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 2); + transport2.simulateMessage(request(2, 'reconnect', { + clientId: 'client-consent', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + })); + await reconnectRespPromise; + transport2.simulateMessage(notification('dispatchAction', { + channel: 'ahp-root://', + clientSeq: 1, + action: { type: ActionType.RootConfigChanged, config: {} }, + })); + + assert.deepStrictEqual(agentService.handledClientContexts.at(-1), { + clientType: 'agents_window', + connectionKind: 'unknown', + transportKind: 'unknown', + hostLaunchKind: 'vscode_main_process', + }); + }); + + test('attributes telemetry identity independently for concurrent clients', () => { + const clients = [ + connectClient('client-a', undefined, agentsWindowAgentHostClientInfo, { + 'vscode.clientMachineId': 'machine-a', + 'vscode.clientDevDeviceId': 'device-a', + }), + connectClient('client-b', undefined, editorWindowAgentHostClientInfo, { + 'vscode.clientMachineId': 'machine-b', + 'vscode.clientDevDeviceId': 'device-b', + }), + ]; + + for (const client of clients) { + client.simulateMessage(notification('dispatchAction', { + channel: 'ahp-root://', + clientSeq: 1, + action: { type: ActionType.RootConfigChanged, config: {} }, + })); + } + + assert.deepStrictEqual(agentService.handledClientContexts.map(context => ({ + clientType: context?.clientType, + machineId: context?.machineId, + devDeviceId: context?.devDeviceId, + })), [{ + clientType: 'agents_window', + machineId: 'machine-a', + devDeviceId: 'device-a', + }, { + clientType: 'editor_window', + machineId: 'machine-b', + devDeviceId: 'device-b', + }]); + }); + test('reports client topology and attributes actions to the initiating connection', () => { const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); server.simulateConnection(transport); @@ -1289,7 +1364,11 @@ suite('ProtocolServerHandler', () => { protocolVersions: [PROTOCOL_VERSION], clientId: 'tunnel-client', clientInfo: { name: 'vscode-agents-window', version: '1.2.3', title: 'VS Code Agents Window' }, - _meta: { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.DevTunnel }, + _meta: { + 'vscode.clientConnectionKind': AgentHostClientConnectionKind.DevTunnel, + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }, })); transport.simulateMessage(notification('dispatchAction', { channel: 'ahp-root://', @@ -1317,6 +1396,8 @@ suite('ProtocolServerHandler', () => { connectionKind: 'dev_tunnel', transportKind: 'websocket', hostLaunchKind: 'vscode_main_process', + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', }, connectionEvents: [{ eventName: 'agentHost.clientConnection', @@ -1329,6 +1410,8 @@ suite('ProtocolServerHandler', () => { clientImplementationVersion: '1.2.3', connectionKind: 'dev_tunnel', transportKind: 'websocket', + clientMachineId: 'client-machine-id', + clientDevDeviceId: 'client-dev-device-id', protocolVersion: PROTOCOL_VERSION, isReconnect: false, connectedClientCount: 1, @@ -1348,6 +1431,8 @@ suite('ProtocolServerHandler', () => { clientImplementationVersion: '1.2.3', connectionKind: 'dev_tunnel', transportKind: 'websocket', + clientMachineId: 'client-machine-id', + clientDevDeviceId: 'client-dev-device-id', protocolVersion: PROTOCOL_VERSION, isReconnect: false, connectedClientCount: 0, diff --git a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts index 74e9e169e2b..51d61bb41d8 100644 --- a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts @@ -23,6 +23,8 @@ import { IAgentConfigurationService } from '../../../node/agentConfigurationServ import { IAgentHostGitService } from '../../../common/agentHostGitService.js'; import { buildSubagentChatUri } from '../../../common/state/sessionState.js'; import { IDetailedDiffResult, IDiffComputeService } from '../../../common/diffComputeService.js'; +import { AgentHostClientType } from '../../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../../common/agentHostTelemetry.js'; class CountingFileService extends FileService { watcherCount = 0; @@ -78,6 +80,14 @@ suite('Agent Host Edit ARC Reporter', () => { const service = disposables.add(new EditArcReporterService([0, 30, 60], fileService, new TestDiffComputeService(), createNoopGitService(), config, new NullLogService(), telemetry)); await service.reportEdit({ + clientContext: { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }, sessionUri: 'copilotcli:/session-1', turnId: 'turn-1', toolCallId: 'tool-1', @@ -96,9 +106,19 @@ suite('Agent Host Edit ARC Reporter', () => { name: event.name, data: { ...event.data, uniqueEditId: '' }, githubName: telemetry.githubEvents[0]?.name, + githubIdentity: { + initiatorMachineId: telemetry.githubEvents[0]?.properties?.initiatorMachineId, + initiatorDevDeviceId: telemetry.githubEvents[0]?.properties?.initiatorDevDeviceId, + }, }, { name: 'editTelemetry.reportEditArc', data: { + initiatorClientType: 'editor_window', + initiatorConnectionKind: 'remote_extension_host', + initiatorTransportKind: 'message_port', + hostLaunchKind: 'vscode_main_process', + initiatorMachineId: 'client-machine-id', + initiatorDevDeviceId: 'client-dev-device-id', sourceKeyCleaned: 'source:Chat.applyEdits', extensionId: undefined, extensionVersion: undefined, @@ -122,6 +142,10 @@ suite('Agent Host Edit ARC Reporter', () => { currentDeletedLineCount: 1, }, githubName: 'vscode.editTelemetry.reportEditArc', + githubIdentity: { + initiatorMachineId: undefined, + initiatorDevDeviceId: undefined, + }, }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts index 68802f922f3..f8fc4937639 100644 --- a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts @@ -15,6 +15,8 @@ import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFil import { NullLogService } from '../../../../log/common/log.js'; import { NullTelemetryServiceShape } from '../../../../telemetry/common/telemetryUtils.js'; import { EditSurvivalReporterFactory } from '../../../node/shared/editSurvivalReporter.js'; +import { AgentHostClientType } from '../../../common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../../common/agentHostTelemetry.js'; import { buildDefaultChatUri } from '../../../common/state/sessionState.js'; class RecordingTelemetryService extends NullTelemetryServiceShape { @@ -48,6 +50,14 @@ suite('agentHost editSurvivalReporter', () => { await fileService.writeFile(URI.file('/workspace/a.ts'), VSBuffer.fromString('after-text')); const reporter = factory.launch({ + clientContext: { + clientType: AgentHostClientType.EditorWindow, + connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, + transportKind: AgentHostTransportKind.MessagePort, + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + machineId: 'client-machine-id', + devDeviceId: 'client-dev-device-id', + }, sessionUri: 'claude:/session-1', turnId: 'turn-1', toolCallId: 'tc-1', @@ -74,6 +84,12 @@ suite('agentHost editSurvivalReporter', () => { assert.strictEqual(data.agentSessionId, 'session-1'); assert.strictEqual(data.turnId, 'turn-1'); assert.strictEqual(data.toolCallId, 'tc-1'); + assert.strictEqual(data.initiatorClientType, 'editor_window'); + assert.strictEqual(data.initiatorConnectionKind, 'remote_extension_host'); + assert.strictEqual(data.initiatorTransportKind, 'message_port'); + assert.strictEqual(data.hostLaunchKind, 'vscode_main_process'); + assert.strictEqual(data.initiatorMachineId, 'client-machine-id'); + assert.strictEqual(data.initiatorDevDeviceId, 'client-dev-device-id'); assert.strictEqual(data.fileExtension, '.ts'); assert.strictEqual(data.timeDelayMs, 0); assert.strictEqual(data.didFileGetDeleted, 0);