diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 498264f79acaf..dc99255b2b234 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -5,6 +5,7 @@ import { hostname, release } from 'os'; import { Disposable, isDisposable, toDisposable, type DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { getDevDeviceId, getMachineId, getSqmMachineId } from '../../../base/node/id.js'; @@ -40,6 +41,7 @@ export interface IAgentHostTelemetryServiceOptions { } export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { + readonly onDidChangeTelemetryLevel: Event; updateTelemetryLevel(telemetryLevel: TelemetryLevel): void; /** Register an account reader with provider-owned lifetime, never process-wide account properties. */ registerCopilotTelemetryProvider(provider: string, getContext: () => IAgentTelemetryContext): IDisposable; @@ -51,6 +53,8 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT declare readonly _serviceBrand: undefined; private _telemetryLevel: TelemetryLevel; + private readonly _onDidChangeTelemetryLevel = this._register(new Emitter()); + readonly onDidChangeTelemetryLevel: Event = this._onDidChangeTelemetryLevel.event; /** * Whether `/copilot_internal/user` enables enhanced/restricted telemetry. Defaults to `false` @@ -229,7 +233,12 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT } updateTelemetryLevel(telemetryLevel: TelemetryLevel): void { + const previous = this.telemetryLevel; this._telemetryLevel = Math.min(this._telemetryLevel, telemetryLevel); + const next = this.telemetryLevel; + if (next !== previous) { + this._onDidChangeTelemetryLevel.fire(next); + } } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2fa55b6f5a459..e3180fe60d25d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -106,7 +106,7 @@ import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConver import { CopilotGitHubTelemetryForwarder, type ICopilotModelCallCorrelationTelemetry } from './copilotGitHubTelemetryForwarder.js'; import { CopilotGitHubCredentials, CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; -import { AGENT_HOST_COPILOT_CLIENT_NAME, CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getLocalModelSelectionId, isCopilotReasoningEffort, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { AGENT_HOST_COPILOT_CLIENT_NAME, CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getLocalModelSelectionId, isCopilotReasoningEffort, isCopilotSessionTelemetryEnabled, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { createLocalModelRowMeta } from '../../common/meta/localModelStatus.js'; import { errorCodeForLog } from '../../common/logRedaction.js'; import { withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; @@ -1086,12 +1086,18 @@ export class CopilotAgent extends Disposable implements IAgent { this._register(this._githubCredentials.onDidRequestRefresh(() => this._handleCopilotSessionAuthRequired(false))); if (isAgentHostTelemetryService(this._telemetryService)) { this._register(this._telemetryService.registerCopilotTelemetryProvider(this.id, () => this.getTelemetryContext())); + this._register(this._telemetryService.onDidChangeTelemetryLevel(() => { + this._revokeSessionTelemetryIfDisabled().catch(err => + this._logService.error('[Copilot] Failed to revoke session telemetry', err) + ); + })); } this._worktree = worktree; this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined, }); this._lastStartupConfig = this._readClientStartupConfig(); + this._sessionTelemetryRevoked = !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this._customizationInstallations = this._register(new CopilotCustomizationInstallations( @@ -1099,13 +1105,23 @@ export class CopilotAgent extends Disposable implements IAgent { session => this._findSessionChat(session)?.sessionId, async (client, sessionId) => { const copilotClient = client as CopilotClient; - const session = await this._sessionLauncher.createCustomizationPolicySession( + const createSession = () => this._sessionLauncher.createCustomizationPolicySession( copilotClient, sessionId, getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), this._getGitHubSessionCredentials(), this._getGitHubHostUri(), ); + const telemetryEnabledAtStart = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); + let session = await createSession(); + if (telemetryEnabledAtStart && !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { + try { + await session.disconnect(); + } catch { + // best-effort teardown before re-creating with telemetry disabled + } + session = await createSession(); + } return { client, sessionId: session.sessionId, @@ -1180,6 +1196,9 @@ export class CopilotAgent extends Disposable implements IAgent { this._restartClientIfStartupConfigChanged().catch(err => this._logService.error('[Copilot] Failed to apply root config change', err) ); + this._revokeSessionTelemetryIfDisabled().catch(err => + this._logService.error('[Copilot] Failed to revoke session telemetry', err) + ); })); this._register(this._managedSettingsService.onDidChange(() => { this._restartClientIfStartupConfigChanged().catch(err => @@ -1248,6 +1267,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _lastStartupConfig: CopilotAgentStartupConfig; + private _sessionTelemetryRevoked: boolean; private _migrateLegacyEnabledSnapshot: boolean | undefined; private _isSessionSyncEnabled(): boolean { @@ -1650,6 +1670,13 @@ export class CopilotAgent extends Disposable implements IAgent { for (const session of this._allLiveSessions()) { void this._scheduleConnectorRefresh(session); } + if (this._sessionTelemetryRevoked) { + queueMicrotask(() => { + this._disposeIdleTelemetryRevokedSessions().catch(err => + this._logService.error('[Copilot] Failed to dispose telemetry-revoked session', err) + ); + }); + } if (this._pendingClientRestartReasons.size === 0) { return; } @@ -1660,6 +1687,58 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + /** + * Revokes session telemetry on existing runtime sessions when the effective + * telemetry level drops below `USAGE`, without waiting for every chat across + * the host to become idle. Idle sessions and the hidden customization policy + * session are torn down immediately so they cannot be reused with telemetry + * enabled; sessions with in-flight work are marked so they tear down as soon + * as their own turn/subagents finish (or before their next turn in + * {@link _refreshSessionConfiguration}). + */ + private async _revokeSessionTelemetryIfDisabled(): Promise { + if (this._sessionTelemetryRevoked || isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { + return; + } + this._sessionTelemetryRevoked = true; + for (const session of [...this._allLiveSessions(), ...this._sessionsPendingRegistration]) { + session.markTelemetryRevocationRequiresRestart?.(); + } + const policyRelease = this._customizationInstallations.releasePolicySession(); + await Promise.all([ + policyRelease, + this._disposeIdleTelemetryRevokedSessions(), + ]); + } + + /** + * Tears down a telemetry-revoked session through its chat sequencer once it + * has no active turn or subagent, so disposal never races a concurrently + * queued operation on that chat. + */ + private _scheduleTelemetryRevokedSessionDisposal(session: CopilotAgentSession): Promise { + if (this._shutdownPromise || !session.requiresRestartAfterTelemetryRevocation || session.isDisposed || session.hasActiveTurn || session.hasActiveSubagents) { + return Promise.resolve(); + } + return this._queueChat(session.sessionId, session.sessionId, 'disposeTelemetryRevokedSession', async () => { + if (this._shutdownPromise || session.isDisposed || session.hasActiveTurn || session.hasActiveSubagents || this._findSessionBySdkId(session.sessionId) !== session) { + return; + } + const canvasHandoff = session.takeCanvasProjection(); + if (canvasHandoff) { + this._canvasHandoffs.set(session.chatChannelUri, canvasHandoff); + } + await this._destroyLiveSession(session, true); + }).catch(error => this._logService.warn(`[Copilot:${session.sessionId}] Failed to dispose telemetry-revoked session`, error)); + } + + private async _disposeIdleTelemetryRevokedSessions(): Promise { + if (this._shutdownPromise) { + return; + } + await Promise.all(this._allLiveSessions().map(session => this._scheduleTelemetryRevokedSessionDisposal(session))); + } + private async _handleClientOperationFailure(error: unknown, operation: CopilotClientOperation, correlation?: ICopilotFailureCorrelation): Promise { const failureKind = classifyCopilotClientOperationFailure(error); if (!failureKind) { @@ -2489,12 +2568,16 @@ export class CopilotAgent extends Disposable implements IAgent { if (token.isCancellationRequested) { throw new CancellationError(); } - const current = this._findSessionChat(session); + const context = this._resolveMcpRequestChatContext(controlChat.chat); + if (!context) { + throw new Error('Cannot start an MCP server because the session runtime is no longer available.'); + } + const current = context.target ?? await this._ensureResolvedChatSession(context); if (!current) { throw new Error('Cannot start an MCP server because the session runtime is no longer available.'); } return this._refreshSessionConfiguration( - this._resolveSendChatContext(controlChat.chat), + context, current, undefined, { operation: 'startMcpServer', allowRestart: 'whenIdle', token }, @@ -5775,7 +5858,7 @@ export class CopilotAgent extends Disposable implements IAgent { : raceCancellationError(activeClient.snapshot(context.chatKey, anchor, selectedAgentUri), waitToken), ]); const primaryDirectoryChanged = !!storedWorkingDirectory && !isEqual(entry.workingDirectory, storedWorkingDirectory); - const structuralRestartReason = !entry.requiresRestartAfterWorkingDirectoryChange && !entry.requiresRestartAfterModelChange && activeClient && currentSnapshot + const structuralRestartReason = !entry.requiresRestartAfterWorkingDirectoryChange && !entry.requiresRestartAfterModelChange && !entry.requiresRestartAfterTelemetryRevocation && activeClient && currentSnapshot ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot, anchor), waitToken) : undefined; const currentDisabledRootMcpServers = currentSnapshot @@ -5788,6 +5871,7 @@ export class CopilotAgent extends Disposable implements IAgent { const refreshReason = (entry.requiresRestartAfterWorkingDirectoryChange ? 'workingDirectoryChanged' : undefined) ?? (primaryDirectoryChanged ? 'primaryDirectoryChanged' : undefined) ?? (entry.requiresRestartAfterModelChange ? 'hydraFusionModelChanged' : undefined) + ?? (entry.requiresRestartAfterTelemetryRevocation ? 'telemetryRevoked' : undefined) ?? (rootsChanged ? 'additionalDirectoriesChanged' : undefined) ?? structuralRestartReason ?? (disabledRootMcpServersChanged ? 'disabledRootMcpServersChanged' : undefined) @@ -5808,7 +5892,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new CopilotSessionConfigurationBusyError(entry); } this._logService.info(`[Copilot:${context.ownerSessionId}] Session configuration changed, refreshing session: operation=${operation}, sdkSessionId=${entry.sessionId}, chat=${context.chatKey}, turnId=${turnId ?? '(none)'}, reason=${refreshReason}, clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); - const canvasHandoff = entry.takeCanvasProjection(); + const canvasHandoff = entry.takeCanvasProjection() ?? this._canvasHandoffs.get(entry.chatChannelUri); if (canvasHandoff) { this._canvasHandoffs.set(entry.chatChannelUri, canvasHandoff); } @@ -5834,9 +5918,10 @@ export class CopilotAgent extends Disposable implements IAgent { /** Commits handed-over canvases to the replacement the refresh kept, or closes them when there is none. */ private _settleCanvasHandoff(chat: URI, handoff: ICopilotCanvasHandoff, replacement: CopilotAgentSession | undefined): void { - if (this._canvasHandoffs.get(chat) === handoff) { - this._canvasHandoffs.delete(chat); + if (this._canvasHandoffs.get(chat) !== handoff) { + return; } + this._canvasHandoffs.delete(chat); if (!replacement?.adoptCanvasHandoff(handoff)) { this._onDidChatProgress.fire({ kind: 'action', resource: chat, action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }); } @@ -6425,6 +6510,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (isProvisional) { this._provisionalSessions.delete(provisionalKey); } + this._canvasHandoffs.delete(chat); this._chatBackings.delete(chatKey); this._chatOwners.delete(chatKey); this._chatSubagentFormats.delete(chatKey); @@ -6715,9 +6801,15 @@ export class CopilotAgent extends Disposable implements IAgent { if (!lease) { return undefined; } + const canvasHandoff = this._canvasHandoffs.get(chat); + let replacement: CopilotAgentSession | undefined; try { - return await this._retryRestoreAfterClientReplacement(acquireClient => this._resumeChatSessionOnce(context, acquireClient, workingDirectories, stageRecorder)); + replacement = await this._retryRestoreAfterClientReplacement(acquireClient => this._resumeChatSessionOnce(context, acquireClient, workingDirectories, stageRecorder)); + return replacement; } finally { + if (canvasHandoff) { + this._settleCanvasHandoff(chat, canvasHandoff, replacement); + } lease.dispose(); } }); @@ -7439,6 +7531,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (session.hasPendingConnectorRefresh) { await session.refreshConnectors(); } + await session.revalidateTelemetryBeforeRegistration?.(); if (!this._sessionsPendingRegistration.deleteAndLeak(session)) { throw new CancellationError(); } @@ -7469,7 +7562,16 @@ export class CopilotAgent extends Disposable implements IAgent { } private _doResumeSession(sessionId: string, chatChannelUri: URI, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder): Promise { - return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, acquireClient, workingDirectories, stageRecorder)); + const canvasHandoff = this._canvasHandoffs.get(chatChannelUri); + let replacement: CopilotAgentSession | undefined; + return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, acquireClient, workingDirectories, stageRecorder)).then(session => { + replacement = session; + return session; + }).finally(() => { + if (canvasHandoff) { + this._settleCanvasHandoff(chatChannelUri, canvasHandoff, replacement); + } + }); } private async _resumeSessionOnce(sessionId: string, chatChannelUri: URI, acquireClient: () => Promise, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 1bc37ecc5574d..e8f0a47433b17 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -17,7 +17,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, IReference, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IReference, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; @@ -1542,6 +1542,7 @@ export class CopilotAgentSession extends Disposable { private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + private readonly _wrapperDisposables = this._register(new DisposableStore()); /** BYOK providers and models registered on {@link _wrapper}; seeded at launch and grown by {@link syncByokModels}. */ private _registeredByokConfig: ICopilotByokSessionConfig = {}; private readonly _byokSync = new Sequencer(); @@ -1554,6 +1555,7 @@ export class CopilotAgentSession extends Disposable { private _workingDirectoryMutationInProgress = false; private _requiresRestartAfterWorkingDirectoryChange = false; private _requiresRestartAfterModelChange = false; + private _requiresRestartAfterTelemetryRevocation = false; private readonly _slashCommandProvider: CopilotSlashCommandProvider; /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ private _lastAppliedMode: CopilotSdkMode | undefined; @@ -3206,6 +3208,14 @@ export class CopilotAgentSession extends Disposable { this._requiresRestartAfterModelChange = true; } + get requiresRestartAfterTelemetryRevocation(): boolean { + return this._requiresRestartAfterTelemetryRevocation; + } + + markTelemetryRevocationRequiresRestart(): void { + this._requiresRestartAfterTelemetryRevocation = true; + } + get requiresMcpLaunchConfigurationRefresh(): boolean { this._markMcpLaunchConfigurationDirty(); return this._mcpLaunchConfigurationDirty; @@ -3632,41 +3642,6 @@ export class CopilotAgentSession extends Disposable { if (this._store.isDisposed) { throw new CancellationError(); } - const wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); - // The session may have been disposed while we were awaiting the - // launcher. If so, dispose the freshly-created wrapper and - // skip subscribing — registering on a disposed store would leak. - if (this._store.isDisposed) { - wrapper.dispose(); - throw new CancellationError(); - } - this._wrapper = this._register(wrapper); - this._registeredByokConfig = wrapper.launchByokConfig; - this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); - this._canvasExtensions = undefined; - this._ignoredRestoredCanvasInstanceIds.clear(); - if (this._launchPlan.kind === 'resume') { - for (const canvas of wrapper.session.openCanvases) { - if (!this._canvasByInstanceId.has(canvas.instanceId)) { - this._ignoredRestoredCanvasInstanceIds.add(canvas.instanceId); - } - } - } - if (!wrapper.canvasRuntimeEnabled) { - this._canvasByInstanceId.clear(); - } - const samplingInterest = await wrapper.session.rpc.eventLog.registerInterest({ eventType: 'sampling.requested' }); - if (this._store.isDisposed) { - throw new CancellationError(); - } - wrapper.setBeforeDisconnect(async () => { - await Promise.allSettled(this._pendingOutputCaptures); - try { - await this._awaitControlPlaneRpc('rpc.eventLog.releaseInterest', wrapper.session.rpc.eventLog.releaseInterest({ handle: samplingInterest.handle })); - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); - } - }); this._register(this._customizationEnablementService.onDidChange(event => { if (!event.sessions.includes(this.launchOwner.toString())) { return; @@ -3674,25 +3649,8 @@ export class CopilotAgentSession extends Disposable { this._markMcpLaunchConfigurationDirty(); this._reconcileMcpServerEnablement().catch(error => this._logService.error(error, `[Copilot:${this.sessionId}] Failed to reconcile MCP enablement after customizations changed`)); })); - this._subscribeToEvents(); - this._subscribeToSdkEvents(); - this._readCommittedAutoTier(wrapper); - this._subscribeForMemoInvalidation(); - this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); - await this._sandbox.updateDiagnostics(); - await this._waitForCanvasExtensions(wrapper); - await this._reconcileCanvasHandoff(wrapper); - await this._syncShellInitScript(); - this._promptCacheState = this._promptCache.read(this.resourceUri); - if (this._launchPlan.kind === 'resume') { - await this._refreshSessionUsageMetrics(); - if (this._store.isDisposed) { - throw new CancellationError(); - } - // Warm the persisted-event reconstruction so canonical subagent names are known before the next turn labels agent reads. - this._getMappedEvents().catch(() => { /* surfaced to whichever caller reads the messages */ }); - } + await this._initializeSdkWrapper(); // Advertise the agent host's server tools for this session so clients // see them as server-provided. Execution happens in-process via the SDK @@ -3700,6 +3658,104 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } + /** + * Re-launches the underlying SDK session if telemetry was revoked after + * {@link initializeSession} finished its launch loop (e.g. while + * `beforeRegistration` or `refreshConnectors` was running). + */ + async revalidateTelemetryBeforeRegistration(): Promise { + while (!this._store.isDisposed && this._requiresRestartAfterTelemetryRevocation) { + await this._relaunchSdkWrapperForTelemetryRevocation(); + } + } + + private async _initializeSdkWrapper(): Promise { + while (true) { + this._requiresRestartAfterTelemetryRevocation = false; + const wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); + if (this._store.isDisposed) { + wrapper.dispose(); + throw new CancellationError(); + } + if (this._requiresRestartAfterTelemetryRevocation) { + await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); + wrapper.dispose(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + continue; + } + this._wrapperDisposables.clear(); + this._wrapper = this._wrapperDisposables.add(wrapper); + this._registeredByokConfig = wrapper.launchByokConfig; + this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); + this._canvasExtensions = undefined; + this._ignoredRestoredCanvasInstanceIds.clear(); + if (this._launchPlan.kind === 'resume') { + for (const canvas of wrapper.session.openCanvases) { + if (!this._canvasByInstanceId.has(canvas.instanceId)) { + this._ignoredRestoredCanvasInstanceIds.add(canvas.instanceId); + } + } + } + if (!wrapper.canvasRuntimeEnabled) { + this._canvasByInstanceId.clear(); + } + const samplingInterest = await wrapper.session.rpc.eventLog.registerInterest({ eventType: 'sampling.requested' }); + if (this._store.isDisposed) { + throw new CancellationError(); + } + wrapper.setBeforeDisconnect(async () => { + await Promise.allSettled(this._pendingOutputCaptures); + try { + await this._awaitControlPlaneRpc('rpc.eventLog.releaseInterest', wrapper.session.rpc.eventLog.releaseInterest({ handle: samplingInterest.handle })); + } catch (error) { + this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); + } + }); + this._subscribeToEvents(this._wrapperDisposables); + this._subscribeToSdkEvents(this._wrapperDisposables); + this._readCommittedAutoTier(wrapper); + this._subscribeForMemoInvalidation(this._wrapperDisposables); + this._subscribeForInstructionsCollectedTelemetry(this._wrapperDisposables); + await this._sandbox.updateDiagnostics(); + await this._waitForCanvasExtensions(wrapper); + await this._reconcileCanvasHandoff(wrapper); + await this._syncShellInitScript(); + this._promptCacheState = this._promptCache.read(this.resourceUri); + if (this._launchPlan.kind === 'resume') { + await this._refreshSessionUsageMetrics(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + // Warm the persisted-event reconstruction so canonical subagent names are known before the next turn labels agent reads. + this._getMappedEvents().catch(() => { /* surfaced to whichever caller reads the messages */ }); + } + if (!this._requiresRestartAfterTelemetryRevocation) { + return; + } + await this._disconnectAndClearWrapper(); + } + } + + private async _relaunchSdkWrapperForTelemetryRevocation(): Promise { + await this._disconnectAndClearWrapper(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + await this._initializeSdkWrapper(); + } + + private async _disconnectAndClearWrapper(): Promise { + const wrapper = this._wrapper; + if (wrapper) { + await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); + } + this._wrapperDisposables.clear(); + this._lastAppliedShellInitScripts = undefined; + this._registeredShellInitScriptPath = undefined; + } + /** Persists the host-owned catalog title through the runtime's naming API. */ async synchronizeTitle(title: string): Promise { const name = Array.from(title.trim()).slice(0, 100).join(''); @@ -7347,11 +7403,11 @@ export class CopilotAgentSession extends Disposable { // ---- event wiring ------------------------------------------------------- - private _subscribeToEvents(): void { + private _subscribeToEvents(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onSystemNotification(e => { + store.add(wrapper.onSystemNotification(e => { if (e.data.kind.type === 'shell_completed') { this._nonPtyShellTerminals.completeBackgroundShell(e.data.kind.shellId, e.data.kind.exitCode); } @@ -7415,7 +7471,7 @@ export class CopilotAgentSession extends Disposable { // right boundary. The DB only sets `event_id` when it's NULL, // so doing this for synthetic injections would permanently // pin the wrong event to the turn. - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { if (e.agentId) { this._resumeSubagentForEvent(e, { text: e.data.content, origin: { kind: MessageKind.User } }); return; @@ -7458,7 +7514,7 @@ export class CopilotAgentSession extends Disposable { this._onSteeringReady?.(this); })); - this._register(wrapper.onMessageDelta(e => { + store.add(wrapper.onMessageDelta(e => { this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message_delta')) { @@ -7525,7 +7581,7 @@ export class CopilotAgentSession extends Disposable { } } }; - this._register(wrapper.onMessage(e => { + store.add(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.message')) { @@ -7593,15 +7649,15 @@ export class CopilotAgentSession extends Disposable { renderMessage(e, parentToolCallId, isLastMessageChunk); })); - this._register(wrapper.onSamplingRequested(e => { + store.add(wrapper.onSamplingRequested(e => { void this._handleSamplingRequest(e.data); })); - this._register(wrapper.onSamplingCompleted(e => { + store.add(wrapper.onSamplingCompleted(e => { this._completeSamplingRequest(e.data.requestId); })); // TODO@connor4312: Remove this correlation once the SDK permission callback includes auto-approval data. - this._register(wrapper.onPermissionRequested(e => { + store.add(wrapper.onPermissionRequested(e => { const toolCallId = e.data.permissionRequest.toolCallId; if (!toolCallId) { return; @@ -7624,7 +7680,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onPermissionCompleted(e => { + store.add(wrapper.onPermissionCompleted(e => { const toolCallId = e.data.toolCallId; if (!toolCallId) { return; @@ -7648,7 +7704,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onToolCallDelta(e => { + store.add(wrapper.onToolCallDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Tool call delta: ${e.data.toolName ?? ''} (${e.data.toolCallId})`); this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.tool_call_delta')) { @@ -7903,7 +7959,7 @@ export class CopilotAgentSession extends Disposable { } }; this._surfaceProvisionalFusionToolStart = handleToolStart; - this._register(wrapper.onToolStart(e => { + store.add(wrapper.onToolStart(e => { if (this._surfacedProvisionalFusionToolCallIds.has(e.data.toolCallId)) { // The committed re-emission of a tool call already shown from its provisional phase. this._logService.trace(`[Copilot:${sessionId}] Ignoring committed Fusion tool start already surfaced: ${e.data.toolCallId}`); @@ -8128,7 +8184,7 @@ export class CopilotAgentSession extends Disposable { } turn?.trackToolCompletion(completion); }; - this._register(wrapper.onToolComplete(e => { + store.add(wrapper.onToolComplete(e => { if (this._surfacedProvisionalFusionToolCallIds.delete(e.data.toolCallId) && !this._activeToolCalls.has(e.data.toolCallId)) { this._logService.trace(`[Copilot:${sessionId}] Ignoring committed Fusion tool completion already surfaced: ${e.data.toolCallId}`); return; @@ -8136,7 +8192,7 @@ export class CopilotAgentSession extends Disposable { handleToolComplete(e); })); - this._register(wrapper.onProvisionalFusionEvent(e => { + store.add(wrapper.onProvisionalFusionEvent(e => { if (e.type === 'assistant.message') { // Review phases show their critique when they complete. Everything else a phase says belongs to its chat; only the committed answer becomes the response. if (e.agentId || e.data.fusion?.conversationScope === 'review') { @@ -8170,7 +8226,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onIdle(async e => { + store.add(wrapper.onIdle(async e => { this._logService.info(`[Copilot:${sessionId}] Session idle`); const idleFromFailedExecution = !e.agentId && !e.data.aborted && this._failedExecutionIdlePending; if (!e.agentId) { @@ -8257,7 +8313,7 @@ export class CopilotAgentSession extends Disposable { // `skill.invoked` event with the resolved SKILL.md path. Synthesize a // tool-start/complete pair from the latter so the UI can render a // clickable file link, matching the `view`-tool display style. - this._register(wrapper.onSkillInvoked(e => { + store.add(wrapper.onSkillInvoked(e => { this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) { @@ -8303,7 +8359,7 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId); })); - this._register(wrapper.onSubagentStarted(e => { + store.add(wrapper.onSubagentStarted(e => { if (this._dropLateRootTurnEvents) { this._logService.error(`[Copilot:${sessionId}] subagent.started emitted after cancellation; dropping`); return; @@ -8339,15 +8395,15 @@ export class CopilotAgentSession extends Disposable { this._updateSubagentModel(e.data.toolCallId, e.data.model); })); - this._register(wrapper.onSubagentCompleted(e => { + store.add(wrapper.onSubagentCompleted(e => { this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, e.data.cancelled ? { kind: 'cancelled', durationMs: e.data.durationMs } : undefined); })); - this._register(wrapper.onSubagentFailed(e => { + store.add(wrapper.onSubagentFailed(e => { this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { kind: 'failed', message: e.data.error, durationMs: e.data.durationMs }); })); - this._register(wrapper.onSubagentConfigured(e => { + store.add(wrapper.onSubagentConfigured(e => { const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); if (!parentToolCallId) { this._logService.warn(`[Copilot:${sessionId}] Unable to attribute model configuration for unknown subagent agentId=${e.agentId}`); @@ -8359,7 +8415,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onTurnStart(e => { + store.add(wrapper.onTurnStart(e => { if (!e.agentId || !e.data.model) { return; } @@ -8370,7 +8426,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionError(e => { + store.add(wrapper.onSessionError(e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('session.error')) { return; } @@ -8406,7 +8462,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onModelCallFailure(e => { + store.add(wrapper.onModelCallFailure(e => { reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn.value?.clientContext)); })); @@ -8416,7 +8472,7 @@ export class CopilotAgentSession extends Disposable { let lastParentUsageTurnId: string | undefined; let autoModeResolved: { readonly turnId: string; readonly data: NonNullable } | undefined; - this._register(wrapper.onAutoModeResolved(e => { + store.add(wrapper.onAutoModeResolved(e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('session.auto_mode_resolved')) { return; } @@ -8480,7 +8536,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onUsage(e => { + store.add(wrapper.onUsage(e => { this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { return; @@ -8675,7 +8731,7 @@ export class CopilotAgentSession extends Disposable { // // Losing this re-emit to a turn that ended mid-flight costs only the session // total's freshness; the turn's own cost was already reported synchronously. - this._register(wrapper.onUsage(async e => { + store.add(wrapper.onUsage(async e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { return; } @@ -8729,7 +8785,7 @@ export class CopilotAgentSession extends Disposable { // call. One that runs between turns belongs to no turn: it is reflected in the session total // only, rather than being carried onto whatever runs next and inflating an unrelated // response footer by what is often the session's single most expensive call. - this._register(wrapper.onSessionCompactionComplete(async e => { + store.add(wrapper.onSessionCompactionComplete(async e => { const observeUsage = () => { const observedParentToolCallId = this._parentToolCallIdForSubagentEvent(e); if ((!e.agentId || observedParentToolCallId) && !this._dropLateRootTurnEvents) { @@ -8843,7 +8899,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onReasoningDelta(e => { + store.add(wrapper.onReasoningDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.reasoning_delta')) { @@ -8856,7 +8912,7 @@ export class CopilotAgentSession extends Disposable { // (e.g. after the model approves a plan, or after we set the mode // before sending). The SDK and AHP share the same three modes // (`interactive` / `plan` / `autopilot`), so we map directly. - this._register(wrapper.onSessionModeChanged(e => { + store.add(wrapper.onSessionModeChanged(e => { // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) // emit their own `session.mode_changed` events carrying an // `agentId`. @@ -8879,14 +8935,14 @@ export class CopilotAgentSession extends Disposable { // bare top-level entry (`SessionCustomizationUpdated`). Each state // change is also logged (with structured metadata) so it flows to the // agent host's OTLP log stream and the per-server Output channels. - this._register(wrapper.onMcpServersLoaded(() => { + store.add(wrapper.onMcpServersLoaded(() => { // The SDK re-emits a cached loaded snapshot on later turns, not live connection state. this._logService.trace(`[Copilot:${sessionId}] MCP server inventory invalidated by session.mcp_servers_loaded; refreshing rpc.mcp.listConfigured`); void this._refreshMcpServersFromRpc().catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to refresh MCP server inventory after session.mcp_servers_loaded`, err); }); })); - this._register(wrapper.onMcpServerStatusChanged(e => { + store.add(wrapper.onMcpServerStatusChanged(e => { this._mcpLifecycleVersion++; const requirement = this._lastMcpAuthRequirements.get(e.data.serverName); if (requirement && e.data.status !== 'pending') { @@ -8916,16 +8972,16 @@ export class CopilotAgentSession extends Disposable { }); } })); - this._register(wrapper.onMcpOAuthCompleted(e => { + store.add(wrapper.onMcpOAuthCompleted(e => { this._handleMcpOAuthCompleted(e.data.requestId, e.data.outcome); })); - this._register(wrapper.onToolsUpdated(() => { + store.add(wrapper.onToolsUpdated(() => { this._slashCommandProvider.clearCache(); this._refreshReadyMcpToolRoutingCaches(); this._fireMcpToolsListChanged(); })); - this._register(wrapper.onCommandsChanged(() => { + store.add(wrapper.onCommandsChanged(() => { this._slashCommandProvider.clearCache(); })); @@ -9349,24 +9405,24 @@ export class CopilotAgentSession extends Disposable { } /** Invalidates both history scopes when persisted events change, while coalescing idle reads within each scope. */ - private _subscribeForMemoInvalidation(): void { + private _subscribeForMemoInvalidation(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const invalidate = () => this._invalidateMappedEvents(); // New content appended to the log. - this._register(wrapper.onUserMessage(invalidate)); - this._register(wrapper.onTurnStart(invalidate)); - this._register(wrapper.onMessage(invalidate)); - this._register(wrapper.onToolStart(invalidate)); - this._register(wrapper.onToolComplete(invalidate)); - this._register(wrapper.onSubagentStarted(invalidate)); - this._register(wrapper.onSubagentCompleted(invalidate)); - this._register(wrapper.onSubagentFailed(invalidate)); - this._register(wrapper.onTurnEnd(invalidate)); - this._register(wrapper.onSessionError(invalidate)); + store.add(wrapper.onUserMessage(invalidate)); + store.add(wrapper.onTurnStart(invalidate)); + store.add(wrapper.onMessage(invalidate)); + store.add(wrapper.onToolStart(invalidate)); + store.add(wrapper.onToolComplete(invalidate)); + store.add(wrapper.onSubagentStarted(invalidate)); + store.add(wrapper.onSubagentCompleted(invalidate)); + store.add(wrapper.onSubagentFailed(invalidate)); + store.add(wrapper.onTurnEnd(invalidate)); + store.add(wrapper.onSessionError(invalidate)); // In-place rewrites of the persisted log. - this._register(wrapper.onSessionCompactionComplete(invalidate)); - this._register(wrapper.onSessionTruncation(invalidate)); - this._register(wrapper.onSessionSnapshotRewind(invalidate)); + store.add(wrapper.onSessionCompactionComplete(invalidate)); + store.add(wrapper.onSessionTruncation(invalidate)); + store.add(wrapper.onSessionSnapshotRewind(invalidate)); } /** @@ -9374,11 +9430,11 @@ export class CopilotAgentSession extends Disposable { * Attempts to match local chat's `ComputeAutomaticInstructions` * emitter (`src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts`) */ - private _subscribeForInstructionsCollectedTelemetry(): void { + private _subscribeForInstructionsCollectedTelemetry(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { // Skip subagent and SDK-injected messages (matches guard on this event above). if (e.agentId || (e.data.source && e.data.source.toLowerCase() !== 'user')) { return; @@ -9860,13 +9916,13 @@ export class CopilotAgentSession extends Disposable { })(); } - private _subscribeToSdkEvents(): void { + private _subscribeToSdkEvents(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onFusionEvent(event => this._acceptFusionEvent(event))); + store.add(wrapper.onFusionEvent(event => this._acceptFusionEvent(event))); - this._register(wrapper.onUnhandledEvent(e => { + store.add(wrapper.onUnhandledEvent(e => { if (e.type === 'session.binary_asset') { this._logService.trace(`[Copilot:${sessionId}] Binary asset received: ${e.data.mimeType}, ${e.data.byteLength} bytes`); return; @@ -9879,35 +9935,35 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(loggedEvent)}`); })); - this._register(wrapper.onIndexedSearch(e => { + store.add(wrapper.onIndexedSearch(e => { const data = e.data.kind === 'startup' || e.data.kind === 'server_error' ? (({ errorMessage: _, ...data }) => data)(e.data) : e.data; this._logService.trace(`[Copilot:${sessionId}] session.indexed_search: ${safeStringify(data)}`); })); - this._register(wrapper.onExtensionsLoaded(e => { + store.add(wrapper.onExtensionsLoaded(e => { if (wrapper.canvasRuntimeEnabled) { this._canvasExtensions = e.data.extensions; this._markCanvasProjectionLive(); } })); - this._register(wrapper.onCanvasRegistryChanged(() => { + store.add(wrapper.onCanvasRegistryChanged(() => { if (wrapper.canvasRuntimeEnabled && this._canvasPhase !== 'released') { this._markCanvasProjectionLive(); this._publishCanvases(); } })); - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { if (!e.agentId) { this._ignoredRestoredCanvasInstanceIds.clear(); } })); // Only first opens emit recorded; opened also includes provider rehydration. - this._register(wrapper.onCanvasRecorded(e => { + store.add(wrapper.onCanvasRecorded(e => { if (!wrapper.canvasRuntimeEnabled || e.agentId) { return; } @@ -9919,7 +9975,7 @@ export class CopilotAgentSession extends Disposable { ); })); - this._register(wrapper.onCanvasOpened(e => { + store.add(wrapper.onCanvasOpened(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId)) { return; } @@ -9930,14 +9986,14 @@ export class CopilotAgentSession extends Disposable { this._projectOpenedCanvas(e.data); })); - this._register(wrapper.onCanvasClosed(e => { + store.add(wrapper.onCanvasClosed(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId) || !this._canvasByInstanceId.delete(e.data.instanceId)) { return; } this._publishCanvases(); })); - this._register(wrapper.onCanvasUnavailable(e => { + store.add(wrapper.onCanvasUnavailable(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId)) { return; } @@ -9953,15 +10009,15 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionStart(e => { + store.add(wrapper.onSessionStart(e => { this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); })); - this._register(wrapper.onSessionResume(e => { + store.add(wrapper.onSessionResume(e => { this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); })); - this._register(wrapper.onSessionInfo(e => { + store.add(wrapper.onSessionInfo(e => { const attributes: Record = { infoType: e.data.infoType }; if (e.data.tip) { attributes.tip = e.data.tip; @@ -9981,14 +10037,14 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionWarning(e => { + store.add(wrapper.onSessionWarning(e => { this._logService.warn(`[Copilot:${sessionId}] ${e.data.message}`, new OtelData({ warningType: e.data.warningType })); if (e.data.warningType === MANAGED_PLUGINS_EVENT_TYPE && !e.agentId) { this._reportManagedPluginFailure(e.data.message); } })); - this._register(wrapper.onSessionModelChange(e => { + store.add(wrapper.onSessionModelChange(e => { this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); const committedAutoTier = getCommittedAutoTier(e.data.newModel, e.data.autoTier); // The runtime omits `autoTier` on Auto-to-Auto changes that keep its committed preference. @@ -10022,46 +10078,46 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onManagedSettingsResolved(e => { + store.add(wrapper.onManagedSettingsResolved(e => { if (!e.agentId) { void this._managedSettingsResolved.complete(); } this._logService.info(`[Copilot:${sessionId}] Managed settings resolved: source=${e.data.source}, managedKeys=${e.data.managedKeys.join(',') || '(none)'}, bypassPermissionsDisabled=${e.data.bypassPermissionsDisabled}, failClosed=${e.data.failClosed}`); })); - this._register(wrapper.onManagedSettingsEnforced(e => { + store.add(wrapper.onManagedSettingsEnforced(e => { this._logService.warn(`[Copilot:${sessionId}] Managed settings enforced: action=${e.data.action}, setting=${e.data.setting}, escalation=${e.data.escalation ?? '(none)'}, failClosed=${e.data.failClosed}, message=${e.data.message}`); })); - this._register(wrapper.onSessionHandoff(e => { + store.add(wrapper.onSessionHandoff(e => { this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); })); - this._register(wrapper.onSessionTruncation(e => { + store.add(wrapper.onSessionTruncation(e => { this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); })); - this._register(wrapper.onSessionSnapshotRewind(e => { + store.add(wrapper.onSessionSnapshotRewind(e => { this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); })); - this._register(wrapper.onSessionShutdown(e => { + store.add(wrapper.onSessionShutdown(e => { this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, apiDuration=${e.data.totalApiDurationMs}ms`); })); - this._register(wrapper.onSessionUsageInfo(e => { + store.add(wrapper.onSessionUsageInfo(e => { this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); })); - this._register(wrapper.onSessionCompactionStart(() => { + store.add(wrapper.onSessionCompactionStart(() => { this._logService.trace(`[Copilot:${sessionId}] Compaction started`); })); - this._register(wrapper.onSessionCompactionComplete(e => { + store.add(wrapper.onSessionCompactionComplete(e => { this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); })); - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); // Restricted `conversation.messageText` (source=user): the raw user prompt text. Emit only // for genuine human prompts on the main agent — skip subagent turns (driven by the parent) @@ -10073,15 +10129,15 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onPendingMessagesModified(() => { + store.add(wrapper.onPendingMessagesModified(() => { this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); })); - this._register(wrapper.onBackgroundTasksChanged(() => { + store.add(wrapper.onBackgroundTasksChanged(() => { this._refreshBackgroundTasks(); })); - this._register(wrapper.onTurnStart(e => { + store.add(wrapper.onTurnStart(e => { if (e.agentId) { this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1); this._pendingSubagentLifecycleCompletions.delete(e.agentId); @@ -10131,20 +10187,20 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onModelCallFinished(e => { + store.add(wrapper.onModelCallFinished(e => { this._emitModelCallFinished(e); })); - this._register(wrapper.onIntent(e => { + store.add(wrapper.onIntent(e => { this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); this._publishActivity('intent', e.data.intent || undefined); })); - this._register(wrapper.onReasoning(e => { + store.add(wrapper.onReasoning(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); })); - this._register(wrapper.onTurnEnd(e => { + store.add(wrapper.onTurnEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); if (e.agentId) { this._refreshBackgroundTasks(); @@ -10155,7 +10211,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onAbort(e => { + store.add(wrapper.onAbort(e => { this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); if (!e.agentId) { this._cancelFusionEvents(); @@ -10176,7 +10232,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onToolUserRequested(e => { + store.add(wrapper.onToolUserRequested(e => { this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); })); @@ -10214,17 +10270,17 @@ export class CopilotAgentSession extends Disposable { } }; - this._register(wrapper.onToolPartialResult(e => { + store.add(wrapper.onToolPartialResult(e => { this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); appendShellOutput(e.data.toolCallId, () => this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput)); })); - this._register(wrapper.onToolShellOutput(e => { + store.add(wrapper.onToolShellOutput(e => { this._logService.trace(`[Copilot:${sessionId}] Tool shell output: ${e.data.toolCallId} #${e.data.sequence} (${e.data.text.length} chars)`); appendShellOutput(e.data.toolCallId, () => this._nonPtyShellTerminals.appendChunk(e.data.toolCallId, e.data)); })); - this._register(wrapper.onToolProgress(e => { + store.add(wrapper.onToolProgress(e => { this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); const tracked = this._activeToolCalls.get(e.data.toolCallId); if (tracked?.toolName !== CopilotToolName.ImageGeneration) { @@ -10250,37 +10306,37 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId); })); - this._register(wrapper.onSkillInvoked(e => { + store.add(wrapper.onSkillInvoked(e => { this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); })); - this._register(wrapper.onSubagentStarted(e => { + store.add(wrapper.onSubagentStarted(e => { this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); })); - this._register(wrapper.onSubagentCompleted(e => { + store.add(wrapper.onSubagentCompleted(e => { this._seedSubagentDisplayNames([e]); this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); })); - this._register(wrapper.onSubagentFailed(e => { + store.add(wrapper.onSubagentFailed(e => { this._seedSubagentDisplayNames([e]); this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); })); - this._register(wrapper.onSubagentSelected(e => { + store.add(wrapper.onSubagentSelected(e => { this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); })); - this._register(wrapper.onHookStart(e => { + store.add(wrapper.onHookStart(e => { this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); })); - this._register(wrapper.onHookEnd(e => { + store.add(wrapper.onHookEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); })); - this._register(wrapper.onSystemMessage(e => { + store.add(wrapper.onSystemMessage(e => { this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); })); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 8d9324f4f9a3d..958a5cc34a317 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -14,11 +14,12 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; import { getByokLmSelectionModelId, resolveByokLmEnablement, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { LOCAL_MODEL_SENTINEL_PROVIDER_NAME } from '../../common/localModel.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AGENT_HOST_COPILOT_CLIENT_NAME, getCopilotApprovalPolicy, resolveCopilotManagedSettings } from './copilotApprovalPolicy.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -771,6 +772,30 @@ export async function applySandboxConfig(session: CopilotSessionWrapper['session } } +/** + * Whether internal Copilot runtime session telemetry is enabled for a session. + * + * Evaluates the most restrictive level between `ITelemetryService` (which + * captures process launch flags, environment variables, product telemetry + * support, and monotonic ratcheting) and the root configuration's + * `telemetryLevel` (which reflects connected clients before `AgentSideEffects` + * updates `ITelemetryService`). When `telemetryService` is omitted (e.g. in + * lightweight unit-test stubs), falls back to the root configuration alone. + */ +export function isCopilotSessionTelemetryEnabled( + configurationService: Pick, + telemetryService?: Pick, +): boolean { + const configuredLevel = agentHostConfigValueToTelemetryLevel( + configurationService.getRootValue(platformRootSchema, AgentHostTelemetryLevelConfigKey), + ); + const serviceLevel = telemetryService?.telemetryLevel ?? TelemetryLevel.USAGE; + const effectiveLevel = configuredLevel !== undefined + ? Math.min(serviceLevel, configuredLevel) + : serviceLevel; + return effectiveLevel >= TelemetryLevel.USAGE; +} + export class CopilotSessionLauncher implements ICopilotSessionLauncher { /** @@ -793,6 +818,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, @IAgentHostSessionOpenTelemetry private readonly _sessionOpenTelemetry: IAgentHostSessionOpenTelemetry, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { } async createCustomizationPolicySession(client: CopilotClient, sessionId: string, workingDirectory: string, githubCredentials: CopilotGitHubSessionCredentials, githubHostUri: string): Promise { @@ -805,9 +831,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { 'github-mcp-server', ])].sort(); const copilotConnectorsEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) === true; + const enableSessionTelemetry = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); const session = await client.createSession({ sessionId, clientName: AGENT_HOST_COPILOT_CLIENT_NAME, + ...(enableSessionTelemetry ? {} : { enableSessionTelemetry: false }), workingDirectory, enableConfigDiscovery: true, enableSkills: true, @@ -1271,9 +1299,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { finalSessionConfig: Object.keys(mcpServers).sort(), })}`); } + const enableSessionTelemetry = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); return { ...byok, ...disabledMcpServers, + ...(enableSessionTelemetry ? {} : { enableSessionTelemetry: false }), onEvent: event => { runtime.onSessionEvent?.(event); const owner = runtime.launchOwner.toString(); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index ad267d8e2438d..dfe056e0b15a5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -224,9 +224,12 @@ suite('AgentHostTelemetryService', () => { test('uses the launch telemetry level before a client connects and only becomes more restrictive', () => { const delegate = new TestTelemetryService(); const service = disposables.add(new AgentHostTelemetryService(delegate, undefined, undefined, undefined, TelemetryLevel.USAGE)); + const levelChanges: TelemetryLevel[] = []; + disposables.add(service.onDidChangeTelemetryLevel(level => levelChanges.push(level))); service.publicLog('beforeClientLevel', { count: 1 }); service.updateTelemetryLevel(TelemetryLevel.ERROR); + service.updateTelemetryLevel(TelemetryLevel.ERROR); service.publicLog('afterClientLevel', { count: 2 }); service.publicLogError('afterClientLevelError', { count: 3 }); service.updateTelemetryLevel(TelemetryLevel.NONE); @@ -237,11 +240,13 @@ suite('AgentHostTelemetryService', () => { assert.deepStrictEqual({ telemetryLevel: service.telemetryLevel, sendErrorTelemetry: service.sendErrorTelemetry, + levelChanges, events: delegate.events, errorEvents: delegate.errorEvents, }, { telemetryLevel: TelemetryLevel.NONE, sendErrorTelemetry: false, + levelChanges: [TelemetryLevel.ERROR, TelemetryLevel.NONE], events: [{ eventName: 'beforeClientLevel', data: { count: 1 } }], errorEvents: [{ eventName: 'afterClientLevelError', data: { count: 3 } }], }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e06a40b533225..3238faa21b729 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -59,7 +59,7 @@ import { AgentHostEnsembleEnabledSettingId, CopilotCliConfigKey, CopilotCliVSCod import { readAgentModelEnsembleMeta } from '../../common/meta/agentModelMeta.js'; import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { toClientPluginMcpDefaultCwdsMeta, toClientPluginStandaloneMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; @@ -11592,6 +11592,407 @@ suite('CopilotAgent', () => { }); } + test('revokes session telemetry on idle and busy sessions without waiting for global idleness', async () => { + const client = new StopCountingClient([]); + const resumeTelemetryFlags: (boolean | undefined)[] = []; + client.resumeSession = async (_id, config) => { + reportManagedSettings(config); + resumeTelemetryFlags.push(config?.enableSessionTelemetry); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + useRealResumePath: true, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.listChatsToMigrate(); + + const idleSessionUri = AgentSession.uri('copilotcli', 'idle'); + const idleChatUri = defaultChatUri(idleSessionUri); + const workingDirectory = URI.file('/workspace'); + const dbRef = sessionDataService.openDatabase(idleSessionUri); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); + } finally { + dbRef.dispose(); + } + + let idleDisposed = false; + let idleDestroyed = false; + const idle = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'idle', idle, idleChatUri); + + let busyDisposed = false; + let busyDestroyed = false; + const busy = { + hasActiveTurn: true, + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { busy.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { busyDisposed = true; }, + async destroySession() { busyDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'busy', busy); + + configurationService.updateRootConfig({ + [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), + }); + await timeout(0); + + const whileBusy = { + stopCount: client.stopCount, + idleDisposed, + idleDestroyed, + busyDisposed, + busyDestroyed, + busyMarked: busy.requiresRestartAfterTelemetryRevocation, + }; + + // Reusing the previously idle chat while `busy` is still running must relaunch + // it through resumeSession with `enableSessionTelemetry: false` rather than + // reusing the telemetry-enabled session. + await agent.chats.sendMessage(idleChatUri, 'hello after opt-out', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(idleSessionUri, idleChatUri, idleSessionUri)); + + // Once `busy` finishes its turn, its telemetry-enabled session is torn down too. + busy.hasActiveTurn = false; + reportChatTurnEnded(agent); + await timeout(0); + + assert.deepStrictEqual({ + whileBusy, + resumeTelemetryFlags, + afterBusyTurn: { + stopCount: client.stopCount, + busyDisposed, + busyDestroyed, + }, + }, { + whileBusy: { + stopCount: 0, + idleDisposed: true, + idleDestroyed: true, + busyDisposed: false, + busyDestroyed: false, + busyMarked: true, + }, + resumeTelemetryFlags: [false], + afterBusyTurn: { + stopCount: 0, + busyDisposed: true, + busyDestroyed: true, + }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('revokes session telemetry and releases policy sessions when handshake lowers telemetry level', async () => { + const client = new StopCountingClient([]); + const policyCreates: (boolean | undefined)[] = []; + const policyDisconnects: string[] = []; + const policyDeletes: string[] = []; + client.createSession = async config => { + reportManagedSettings(config); + policyCreates.push(config?.enableSessionTelemetry); + return { + sessionId: config?.sessionId ?? 'policy-session', + disconnect: async () => { policyDisconnects.push(config?.sessionId ?? 'policy-session'); }, + rpc: { + gitHubAuth: { + setCredentials: async () => ({ success: true, copilotUserResolved: true }), + }, + }, + } as unknown as CopilotSession; + }; + client.deleteSession = async sessionId => { policyDeletes.push(sessionId); }; + (client.rpc.mcp as unknown as { discover: () => Promise<{ servers: never[]; errors: never[] }> }).discover = async () => ({ servers: [], errors: [] }); + (client.rpc as unknown as { catalog: { search: () => Promise<{ kind: 'succeeded'; searchId: string; candidates: never[]; hasMore: boolean }> } }).catalog = { + search: async () => ({ kind: 'succeeded', searchId: 's1', candidates: [], hasMore: false }), + }; + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const { agent } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test', limit: 10 }); + + let idleDisposed = false; + let idleDestroyed = false; + const idle = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'idle', idle); + + // Simulates ProtocolServerHandler._applyClientTelemetryLevel on initialize/reconnect. + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + await timeout(0); + + // A subsequent marketplace search after telemetry is already disabled must create + // the policy session once with `enableSessionTelemetry: false` (no duplicate retry). + await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'after-disable', limit: 10 }); + + assert.deepStrictEqual({ + idleDisposed, + idleDestroyed, + policyCreates, + policyDisconnects: policyDisconnects.length, + policyDeletes: policyDeletes.length, + }, { + idleDisposed: true, + idleDestroyed: true, + policyCreates: [undefined, false], + policyDisconnects: 1, + policyDeletes: 1, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('revalidates in-flight session launches when telemetry is revoked during SDK launch or post-launch setup', async () => { + for (const stage of ['sdkLaunch', 'postLaunch'] as const) { + for (const pathKind of ['send', 'history'] as const) { + const client = new StopCountingClient([]); + const launchTelemetryFlags: (boolean | undefined)[] = []; + const disconnectedSessions: number[] = []; + const shellInitUpdatesByAttempt = new Map(); + const pauseReached = new DeferredPromise(); + const allowContinue = new DeferredPromise(); + let launchAttempt = 0; + + const handleLaunch = async (config: Parameters[1]) => { + const attempt = ++launchAttempt; + reportManagedSettings(config); + launchTelemetryFlags.push(config?.enableSessionTelemetry); + if (stage === 'sdkLaunch' && attempt === 1) { + pauseReached.complete(); + await allowContinue.p; + } + const mockSession = new MockCopilotSession(`sess-${stage}-${pathKind}`); + const origOptionsUpdate = mockSession.rpc.options.update; + mockSession.rpc.options.update = (async (params?: { shell?: { initScripts?: unknown[] } }) => { + if (params?.shell?.initScripts !== undefined) { + shellInitUpdatesByAttempt.set(attempt, params.shell.initScripts); + if (stage === 'postLaunch' && attempt === 1) { + pauseReached.complete(); + await allowContinue.p; + } + } + return origOptionsUpdate(); + }) as typeof mockSession.rpc.options.update; + const origDisconnect = mockSession.disconnect.bind(mockSession); + mockSession.disconnect = async () => { + disconnectedSessions.push(attempt); + return origDisconnect(); + }; + return mockSession as unknown as CopilotSession; + }; + client.createSession = config => handleLaunch(config); + client.resumeSession = (_id, config) => handleLaunch(config); + + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const { agent, stateManager } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + fileService, + useRealResumePath: true, + rootConfig: { [CopilotCliConfigKey.EnableShellInitScript]: true }, + }); + const sessionUri = AgentSession.uri('copilotcli', `sess-${stage}-${pathKind}`); + const chatUri = defaultChatUri(sessionUri); + const workingDirectory = URI.file('/workspace'); + const now = new Date().toISOString(); + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'copilotcli', + title: 'Test', + status: SessionStatus.Idle, + createdAt: now, + modifiedAt: now, + workingDirectories: [workingDirectory.toString()], + defaultChat: chatUri.toString(), + }); + stateManager.setSessionConfig(sessionUri.toString(), { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TEST_INIT=1\n' }] }, + }); + + try { + await agent.authenticate('https://api.github.com', 'token'); + if (pathKind === 'send') { + await provisionSession(agent, { session: sessionUri, workingDirectories: [workingDirectory] }); + const op = agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)); + await pauseReached.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowContinue.complete(); + await op; + } else { + const dbRef = sessionDataService.openDatabase(sessionUri); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); + } finally { + dbRef.dispose(); + } + await agent.materializeChat(chatUri, exactChatContext(sessionUri, chatUri, sessionUri), undefined); + const op = agent.chats.getMessages(chatUri, exactChatContext(sessionUri, chatUri, sessionUri)); + await pauseReached.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowContinue.complete(); + await op; + } + + assert.deepStrictEqual({ + stage, + pathKind, + launchTelemetryFlags, + disconnectedSessions, + shellInitRegisteredOnAttempts: [...shellInitUpdatesByAttempt.entries()].map(([attempt, scripts]) => ({ attempt, count: scripts.length })), + }, { + stage, + pathKind, + launchTelemetryFlags: [undefined, false], + disconnectedSessions: [1], + shellInitRegisteredOnAttempts: stage === 'sdkLaunch' + ? [{ attempt: 2, count: 1 }] + : [{ attempt: 1, count: 1 }, { attempt: 2, count: 1 }], + }); + } finally { + allowContinue.complete(); + await disposeAgent(agent); + } + } + } + }); + + test('preserves open canvases across telemetry-revoked session teardown and closes them if resume fails', async () => { + for (const resumeSucceeds of [true, false]) { + const client = new StopCountingClient([]); + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + useRealResumePath: true, + }); + const sessionId = `canvas-revoke-${resumeSucceeds}`; + const sessionUri = AgentSession.uri('copilotcli', sessionId); + const chatUri = defaultChatUri(sessionUri); + const workingDirectory = URI.file('/workspace'); + const handoff: ICopilotCanvasHandoff = { + projections: [{ + resource: URI.parse(`ahp-canvas:/${sessionId}/preview`), + canvas: { instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview' }, + }], + }; + const membership: { resource: string; action: unknown }[] = []; + disposables.add(agent.onDidChatProgress(signal => { + if (signal.kind === 'action' && signal.action.type === ActionType.ChatCanvasesChanged) { + membership.push({ resource: signal.resource.toString(), action: signal.action }); + } + })); + const internals = agent as unknown as { + _resumeSessionOnce: (id: string, chatChannelUri: URI, acquireClient: () => Promise) => Promise; + _canvasHandoffs: { get(chat: URI): ICopilotCanvasHandoff | undefined; readonly size: number }; + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const initialSession = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { initialSession.requiresRestartAfterTelemetryRevocation = true; }, + takeCanvasProjection: () => handoff, + }; + setDefaultSessionStub(agent, sessionId, initialSession, chatUri); + + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + await timeout(0); + + const handoffWhileDisconnected = internals._canvasHandoffs.get(chatUri); + + let adopted = false; + internals._resumeSessionOnce = async (_id, _chatChannelUri, acquireClient) => { + await acquireClient(); + if (!resumeSucceeds) { + throw new Error('resume failed after telemetry revocation'); + } + const receivedHandoff = internals._canvasHandoffs.get(chatUri); + const replacement = { + send: async () => { }, + dispose: () => { }, + resetTurnState: () => { }, + adoptCanvasHandoff: (candidate: ICopilotCanvasHandoff) => { + adopted = candidate === receivedHandoff && candidate === handoff; + return adopted; + }, + }; + setDefaultSessionStub(agent, sessionId, replacement, chatUri); + return replacement as unknown as CopilotAgentSession; + }; + + if (resumeSucceeds) { + await agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)); + } else { + await assert.rejects( + () => agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)), + /resume failed after telemetry revocation/, + ); + } + + assert.deepStrictEqual({ + resumeSucceeds, + handoffWhileDisconnected, + adopted, + pendingHandoffsAfter: internals._canvasHandoffs.size, + membership, + }, { + resumeSucceeds, + handoffWhileDisconnected: handoff, + adopted: resumeSucceeds, + pendingHandoffsAfter: 0, + membership: resumeSucceeds + ? [] + : [{ resource: chatUri.toString(), action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }], + }); + } finally { + await disposeAgent(agent); + } + } + }); + test('does not restart when an unrelated config key changes', async () => { const client = new StopCountingClient([]); const logService = new MutableLogService(); @@ -17491,7 +17892,7 @@ suite('CopilotAgent', () => { }); suite('MCP configuration preparation', () => { - async function createHarness(options: { cold?: boolean; failSync?: boolean; syncGate?: Promise } = {}) { + async function createHarness(options: { cold?: boolean; failSync?: boolean; syncGate?: Promise; telemetryService?: ITelemetryService; sdkSessionId?: string } = {}) { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); const pluginDirectory = URI.file('/late-plugin'); @@ -17511,7 +17912,7 @@ suite('CopilotAgent', () => { } } const sessionDataService = disposables.add(new TestSessionDataService()); - const sdkSessionId = 'restored-sdk-session'; + const sdkSessionId = options.sdkSessionId ?? 'restored-sdk-session'; const client = new TestCopilotClient([sdkSession(sdkSessionId, '/workspace')]); const oldRuntime = new MockCopilotSession(sdkSessionId); const newRuntime = new MockCopilotSession(sdkSessionId); @@ -17544,8 +17945,8 @@ suite('CopilotAgent', () => { await resumeGate; return newRuntime as unknown as CopilotSession; }; - const { agent, stateManager, worktreeIsolation } = createTestAgentContext(disposables, { - copilotClient: client, useRealResumePath: true, sessionDataService, fileService, pluginManager: new PluginManager(), + const { agent, stateManager, worktreeIsolation, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, useRealResumePath: true, sessionDataService, fileService, pluginManager: new PluginManager(), telemetryService: options.telemetryService, }); disposables.add(toDisposable(() => agent.dispose())); await agent.authenticate('https://api.github.com', 'token'); @@ -17577,7 +17978,7 @@ suite('CopilotAgent', () => { })); await syncStarted.p; return { - agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, disconnectStarted, sessionDataService, worktreeIsolation, + agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, disconnectStarted, sessionDataService, worktreeIsolation, configurationService, serverId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=late-server`, otherId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=other-server`, live: () => chatEntriesBySdkId(agent).get(sdkSessionId)!.chatSession, @@ -17612,6 +18013,65 @@ suite('CopilotAgent', () => { } }); + for (const sdkSessionId of ['mcp-refresh-session', 'restored-sdk-session']) { + for (const cancelled of [false, true]) { + test(`Start resumes an idle chat after telemetry opt-out (${sdkSessionId}, ${cancelled ? 'cancelled' : 'completed'})`, async () => { + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const h = await createHarness({ telemetryService, sdkSessionId }); + const gate = new DeferredPromise(); + try { + h.configurationService.updateRootConfig({ + [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), + }); + await h.disconnectStarted.p; + await h.agent.stopMcpServer(h.session, h.serverId); + assert.strictEqual(chatEntriesBySdkId(h.agent).has(sdkSessionId), false); + + const source = disposables.add(new CancellationTokenSource()); + if (cancelled) { + h.blockResume(gate.p); + } + const start = h.agent.startMcpServer(h.session, h.serverId, source.token); + const outcome = start.then(() => undefined, error => error); + if (cancelled) { + await Promise.race([h.refreshStarted.p, start]); + source.cancel(); + assert.ok(isCancellationError(await outcome)); + const stop = h.agent.stopMcpServer(h.session, h.serverId); + gate.complete(); + await stop; + } else { + await start; + await h.agent.startMcpServer(h.session, h.serverId); + } + assert.deepStrictEqual({ + ids: h.resumedIds, + telemetry: h.resumeConfigs.map(config => config?.enableSessionTelemetry), + plugins: h.resumeConfigs[1]?.pluginDirectories, + oldStarts: h.oldRuntime.mcpStartCalls, + starts: h.newRuntime.mcpStartCalls, + stops: h.newRuntime.mcpStopCalls, + turns: h.oldRuntime.sendCalls + h.newRuntime.sendCalls, + }, { + ids: [sdkSessionId, sdkSessionId], + telemetry: [undefined, false], + plugins: [h.pluginDirectory.fsPath, h.healthyDirectory.fsPath], + oldStarts: [], + starts: cancelled ? [] : ['late-server', 'late-server'], + stops: cancelled ? ['late-server'] : [], + turns: 0, + }); + } finally { + gate.complete(); + await disposeAgent(h.agent); + } + }); + } + } + for (const ending of ['idle', 'stop', 'dispose'] as const) { test(`defers refresh during an active turn and handles ${ending}`, async () => { const h = await createHarness({ failSync: true }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 088cffcb44e76..3794ded80a460 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -17,8 +17,9 @@ import { ServiceCollection } from '../../../instantiation/common/serviceCollecti import { ILogService, LogLevel, NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import type { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; +import { type ITelemetryService, TelemetryConfiguration, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, platformSessionSchema, type SchemaValues } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, type SchemaValues } from '../../common/agentHostSchema.js'; import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { readSessionSandboxState } from '../../common/meta/agentSandboxStateMeta.js'; @@ -127,7 +128,7 @@ const noopSessionOpenTelemetry: IAgentHostSessionOpenTelemetry = { sdkResumeFallbackCreated: () => { }, }; -function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService): CopilotSessionLauncher { +function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService, telemetryService?: ITelemetryService): CopilotSessionLauncher { const configurationService = configuration ?? { getRootValue: (_schema: unknown, key: string) => rootValues[key], getRootConfigValues: () => rootValues, @@ -156,6 +157,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin withTraceContext: (_context: undefined, fn: () => T): T => fn(), } as unknown as IAgentHostOTelService, sessionOpenTelemetry, + telemetryService ?? ({ telemetryLevel: TelemetryLevel.USAGE } as ITelemetryService), ); } @@ -1597,6 +1599,94 @@ suite('CopilotSessionLauncher shared session config', () => { await launcher.disposeByokProxyHandle(); } }); + + test('passes enableSessionTelemetry: false on create, resume, and policy sessions when telemetry is below usage', async () => { + const session = { + sessionId: 'session-1', + on: () => () => { }, + disconnect: async () => { }, + rpc: { + options: { update: async () => ({ success: true }) }, + gitHubAuth: { + setCredentials: async () => ({ success: true, copilotUserResolved: true }), + }, + }, + } as unknown as CopilotSession; + const captured: { create?: boolean; resume?: boolean; policy?: boolean }[] = []; + + for (const scenario of [ + { rootValues: {}, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ON }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.OFF }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ERROR }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.CRASH }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ON }, telemetryLevel: TelemetryLevel.NONE }, + { rootValues: {}, telemetryLevel: TelemetryLevel.ERROR }, + ]) { + const entry: { create?: boolean; resume?: boolean; policy?: boolean } = {}; + const client = { + createSession: async (config: Parameters[0]) => { + reportManagedSettings(config); + if (config?.sessionId === 'hidden-policy') { + entry.policy = config.enableSessionTelemetry; + } else { + entry.create = config?.enableSessionTelemetry; + } + return session; + }, + resumeSession: async (_sessionId: string, config: Parameters[1]) => { + reportManagedSettings(config); + entry.resume = config?.enableSessionTelemetry; + return session; + }, + rpc: { + mcp: { discover: async () => ({ servers: [], errors: [] }) }, + managedSettings: createUnmanagedCopilotSettings(), + account: new class extends mock() { }, + sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) }, + }, + }; + const launcher = createTestLauncher( + undefined, + scenario.rootValues, + new NullLogService(), + noopSessionOpenTelemetry, + undefined, + { telemetryLevel: scenario.telemetryLevel } as ITelemetryService, + ); + const basePlan = { + client: client as unknown as CopilotSessionLaunchPlan['client'], + extensionSdkPath: '/copilot-sdk', + sessionId: 'session-1', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), + }; + const sessions = new DisposableStore(); + try { + sessions.add(await launcher.launch({ ...basePlan, kind: 'create', model: undefined }, testRuntime)); + sessions.add(await launcher.launch({ ...basePlan, kind: 'resume', fallback: { model: undefined } }, testRuntime)); + await launcher.createCustomizationPolicySession(client as unknown as CopilotClient, 'hidden-policy', '/copilot-home', CopilotGitHubSessionCredentials.fromToken('token'), 'https://github.com'); + captured.push(entry); + } finally { + sessions.dispose(); + await launcher.disposeByokProxyHandle(); + } + } + + assert.deepStrictEqual(captured, [ + { create: undefined, resume: undefined, policy: undefined }, + { create: undefined, resume: undefined, policy: undefined }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + ]); + }); }); suite('CopilotSessionLauncher local index', () => {