agentHost: Attribute telemetry to initiating clients (#330982)

* agentHost: attribute telemetry to initiating clients

Plumb VS Code client telemetry identity through AHP and attach the complete known initiator context to attributable Agent Host events without replacing host identity. (Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: address client attribution feedback

Fix test registration, propagate initiator context through provider operations and Claude edit telemetry, and retain identity in AHP diagnostic logs. (Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
roblourens
2026-08-15 01:27:07 +00:00
committed by GitHub
co-authored by Copilot
parent d8baf2284e
commit bb7e37086f
42 changed files with 904 additions and 180 deletions
@@ -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<string, unknown> } | Record<string, never> {
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 } : {};
}
@@ -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:
@@ -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
@@ -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<string, unknown> | 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<string, unknown> | undefined {
const meta: Record<string, unknown> = {};
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<string, unknown> | undefined): AgentHostClientConnectionKind {
@@ -70,6 +82,19 @@ export function readClientConnectionKind(meta: Record<string, unknown> | undefin
}
}
export function readClientMachineId(meta: Record<string, unknown> | undefined): string | undefined {
return readClientTelemetryIdentity(meta, CLIENT_MACHINE_ID_META_KEY);
}
export function readClientDevDeviceId(meta: Record<string, unknown> | undefined): string | undefined {
return readClientTelemetryIdentity(meta, CLIENT_DEV_DEVICE_ID_META_KEY);
}
function readClientTelemetryIdentity(meta: Record<string, unknown> | 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:
@@ -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<ProtocolURI> {
private async _computeTurnChangeset(session: ProtocolURI, turnId: string, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise<ProtocolURI> {
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<ProtocolURI> {
private async _computeUncommittedChangeset(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise<ProtocolURI> {
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<void> {
private async _doComputeStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus, reportTelemetry: boolean = false, clientContext?: IAgentHostClientTelemetryContext): Promise<void> {
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);
@@ -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<ChangesetComputedEvent, 'provider' | 'agentSessionId' | 'turnId'>): void {
function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit<ChangesetComputedEvent, 'provider' | 'agentSessionId' | 'turnId' | keyof IAgentHostInitiatorTelemetry>, clientContext?: IAgentHostClientTelemetryContext): void {
telemetryService.publicLog2<ChangesetComputedEvent, ChangesetComputedClassification>('agentHost.changesetComputed', {
...toInitiatorTelemetry(clientContext),
provider: URI.parse(session).scheme,
agentSessionId: AgentSession.id(session),
...(turnId !== undefined ? { turnId } : {}),
@@ -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, 'elapsed'> = () => 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,
@@ -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
@@ -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<IAgentHostExecutionModeChangedEvent, IAgentHostExecutionModeChangedClassification>('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<IAgentHostToolCallDetailsEvent, IAgentHostToolCallDetailsClassification>('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<IAgentHostToolApprovalEvent, IAgentHostToolApprovalClassification>('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<IAgentHostTurnCompletedEvent, IAgentHostTurnCompletedClassification>('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<IAgentHostTurnFailedEvent, IAgentHostTurnFailedClassification>('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<IAgentHostTurnHungEvent, IAgentHostTurnHungClassification>('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<IAgentHostHungTurnCompletedEvent, IAgentHostHungTurnCompletedClassification>('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<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>('languageModelToolInvoked', {
this._telemetryService.publicLog2<LanguageModelToolInvokedEvent & IAgentHostInitiatorTelemetry, LanguageModelToolInvokedClassification & IAgentHostInitiatorClassification>('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<IAgentHostAskQuestionsToolInvokedEvent, IAgentHostAskQuestionsToolInvokedClassification>('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<IAgentHostToolCallStalledEvent, IAgentHostToolCallStalledClassification>('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<IAgentHostStalledToolCallCompletedEvent, IAgentHostStalledToolCallCompletedClassification>('agentHost.stalledToolCallCompleted', {
...toInitiatorTelemetry(report.clientContext),
provider: report.provider,
agentSessionId: AgentSession.id(session),
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
@@ -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<string>());
private readonly _stalledToolCalls = new Map<string, IStalledToolCall>();
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,
@@ -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,
@@ -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];
@@ -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<void> {
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<void>[] = [];
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;
@@ -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 });
}
@@ -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<void> {
async send(prompt: SDKUserMessage, turnId: string, resource: URI, workingDirectories?: readonly URI[], switchTransport?: ClaudeTransport, hostInstructions?: readonly string[], clientContext?: IAgentHostClientTelemetryContext): Promise<void> {
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;
}
@@ -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<string, { readonly filePath: string; readonly toolName: string; readonly toolInput: unknown; readonly modelId: string | undefined }>();
private readonly _editToolPaths = new Map<string, { readonly filePath: string; readonly toolName: string; readonly toolInput: unknown; readonly modelId: string | undefined; readonly clientContext?: IAgentHostClientTelemetryContext }>();
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<SDKMessage, { type: 'assistant' }>, mode?: PermissionMode): void {
observeAssistant(message: Extract<SDKMessage, { type: 'assistant' }>, 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);
}
@@ -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<void>;
readonly steeringPendingId?: string;
@@ -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<void> {
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);
}
@@ -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<void> {
async send(prompt: SDKUserMessage, turnId: string, clientContext?: IAgentHostClientTelemetryContext): Promise<void> {
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<void>(),
};
@@ -476,6 +478,7 @@ export class ClaudeSdkPipeline extends Disposable {
sdkMessage: prompt,
sdkUuid,
turnId: parent.turnId,
clientContext: parent.clientContext,
stopWatch: parent.stopWatch,
deferred: new DeferredPromise<void>(),
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}`);
@@ -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<void> => {
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<void> {
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<void> {
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<void> {
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<void> {
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);
@@ -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<void> {
async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[], clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): Promise<void> {
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<AgentHostInstructionsCollectedEvent, AgentHostInstructionsCollectedClassification>('agentHost.instructionsCollected', {
...toInitiatorTelemetry(clientContext),
provider: this.resourceUri.scheme,
agentSessionId: AgentSession.id(this.resourceUri),
isSubagentSession: isSubagentSession(this.resourceUri),
@@ -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.' };
@@ -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<Record<string, unknown>> | undefined): void {
export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryService, session: URI, toolCallId: string, toolName: string, toolInput: Readonly<Record<string, unknown>> | undefined, clientContext?: IAgentHostClientTelemetryContext): void {
const operation = getCopilotTodoStoreOperationData(toolName, toolInput);
if (!operation) {
return;
}
telemetryService.publicLog2<TodoStoreOperationEvent, TodoStoreOperationClassification>('todoStoreOperation', {
...toInitiatorTelemetry(clientContext),
...operation,
toolCallId,
provider: session.scheme,
@@ -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<string, unknown> | 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 } : {}),
};
}
@@ -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<IEditArcTelemetryEvent, IEditArcTelemetryClassification>('editTelemetry.reportEditArc', event);
this._telemetryService.publicLog2<IEditArcTelemetryEvent & IAgentHostInitiatorTelemetry, IEditArcTelemetryClassification & IAgentHostInitiatorClassification>('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,
@@ -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<IEditSurvivalTelemetryEvent, IEditSurvivalTelemetryClassification>(
'agentHost.trackEditSurvival',
{
...toInitiatorTelemetry(this._params.clientContext),
provider: AgentSession.provider(sessionUri) ?? 'unknown',
modelId: this._params.modelId ?? '',
toolName: this._params.toolName ?? '',
@@ -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<ToolResultFileEditContent | undefined> {
async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string, toolName: string, toolInput: unknown, modelId: string | undefined, clientContext?: IAgentHostClientTelemetryContext): Promise<ToolResultFileEditContent | undefined> {
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,
@@ -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<string, unknown> })._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<string, unknown> })._meta, undefined);
noTelemetryTransport.fireMessage({
jsonrpc: '2.0',
id: noTelemetryInitialize.id,
result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] },
});
await noTelemetryConnectPromise;
});
async function flushMicrotasks(): Promise<void> {
// `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<string, unknown> })._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<string, unknown> })._meta,
}, {
clientInfo: agentsWindowAgentHostClientInfo,
meta: {
'vscode.clientMachineId': 'client-machine-id',
'vscode.clientDevDeviceId': 'client-dev-device-id',
},
});
reconnectTransport.fireMessage({
jsonrpc: '2.0',
id: initialize.id,
@@ -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,
@@ -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,
@@ -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,
@@ -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');
@@ -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<string, unknown>;
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');
@@ -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<string, unknown>).isSubagentSession === true);
assert.deepStrictEqual({
initiatorClientType: (event?.data as Record<string, unknown> | undefined)?.initiatorClientType,
initiatorConnectionKind: (event?.data as Record<string, unknown> | undefined)?.initiatorConnectionKind,
initiatorTransportKind: (event?.data as Record<string, unknown> | undefined)?.initiatorTransportKind,
hostLaunchKind: (event?.data as Record<string, unknown> | undefined)?.hostLaunchKind,
initiatorMachineId: (event?.data as Record<string, unknown> | undefined)?.initiatorMachineId,
initiatorDevDeviceId: (event?.data as Record<string, unknown> | 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');
@@ -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,
});
});
@@ -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<string, unknown>;
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,
@@ -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,
@@ -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',
@@ -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<void> => {
this._recordContext('abort', chat, context);
const { session } = this._resolveChatTarget(chat, context);
return this.abortSession(session);
},
changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise<void> => {
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<void> => {
this._recordContext('changeAgent', chatUri, context);
const { session, chat } = this._resolveChatTarget(chatUri, context);
return this.changeAgent(session, agent, chat);
},
@@ -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,
@@ -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: '<uuid>' },
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,
},
});
});
@@ -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);