From d4d6ecfcbd52aadadd58642b572fa4103e457757 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:51:24 -0700 Subject: [PATCH 1/5] agentHost: pass enableSessionTelemetry: false to Copilot runtime when telemetry is off --- .../agentHost/node/copilot/copilotAgent.ts | 65 ++++++++++- .../node/copilot/copilotAgentSession.ts | 9 ++ .../node/copilot/copilotSessionLauncher.ts | 32 ++++- .../agentHost/test/node/copilotAgent.test.ts | 109 +++++++++++++++++- .../test/node/copilotSessionLauncher.test.ts | 89 +++++++++++++- 5 files changed, 298 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 78f6d3fc74800..40b088e47eec8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -105,7 +105,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'; @@ -1051,6 +1051,7 @@ export class CopilotAgent extends Disposable implements IAgent { [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( @@ -1138,6 +1139,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 => @@ -1206,6 +1210,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _lastStartupConfig: CopilotAgentStartupConfig; + private _sessionTelemetryRevoked: boolean; private _migrateLegacyEnabledSnapshot: boolean | undefined; private _isSessionSyncEnabled(): boolean { @@ -1604,6 +1609,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; } @@ -1614,6 +1626,54 @@ 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(AgentSession.id(session.ownerSessionUri), session.sessionId, 'disposeTelemetryRevokedSession', async () => { + if (this._shutdownPromise || session.isDisposed || session.hasActiveTurn || session.hasActiveSubagents || this._findSessionBySdkId(session.sessionId) !== session) { + return; + } + 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) { @@ -5605,7 +5665,7 @@ export class CopilotAgent extends Disposable implements IAgent { : raceCancellationError(activeClient.snapshot(context.chatKey), 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), waitToken) : undefined; const currentDisabledRootMcpServers = currentSnapshot @@ -5618,6 +5678,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) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index de5a14e42cf7a..4b4c576641612 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1545,6 +1545,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; @@ -3184,6 +3185,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; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index bafd1d4ac346a..0d880cda86ca9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -13,11 +13,12 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.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'; @@ -770,6 +771,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 { /** @@ -792,6 +817,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): Promise { @@ -804,9 +830,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, @@ -1244,9 +1272,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.configurationResource.toString(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 0e7a4df3df898..e41c6bf847352 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -57,7 +57,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 } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, 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'; @@ -10475,6 +10475,113 @@ 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; + setDefaultSessionStub(agent, 'idle', { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }, idleChatUri); + + let busyDisposed = false; + let busyDestroyed = false; + const busy = { + hasActiveTurn: true, + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { this.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('does not restart when an unrelated config key changes', async () => { const client = new StopCountingClient([]); const logService = new MutableLogService(); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index a54b880e6ef46..04a206452829d 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, @@ -154,6 +155,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin withTraceContext: (_context: undefined, fn: () => T): T => fn(), } as unknown as IAgentHostOTelService, sessionOpenTelemetry, + telemetryService, ); } @@ -1433,6 +1435,89 @@ 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 }) } }, + } 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')); + 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', () => { From d551c383ab5ef5cc9d70cb70c9a883d3aa973dda Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:12:37 -0700 Subject: [PATCH 2/5] feedback updates --- .../node/agentHostTelemetryService.ts | 9 + .../agentHost/node/copilot/copilotAgent.ts | 47 +++- .../node/copilot/copilotAgentSession.ts | 34 ++- .../node/agentHostTelemetryService.test.ts | 5 + .../agentHost/test/node/copilotAgent.test.ts | 239 ++++++++++++++++++ 5 files changed, 318 insertions(+), 16 deletions(-) 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 40b088e47eec8..a73d37acebb76 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1045,6 +1045,11 @@ 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?.({ @@ -1059,12 +1064,21 @@ 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(), ); + let session = await createSession(); + if (this._sessionTelemetryRevoked && !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, @@ -1663,6 +1677,10 @@ export class CopilotAgent extends Disposable implements IAgent { 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)); } @@ -5697,7 +5715,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new CopilotSessionConfigurationBusyError(entry); } this._logService.info(`[Copilot:${context.configurationId}] 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); } @@ -5723,9 +5741,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 } }); } @@ -6290,6 +6309,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (isProvisional) { this._provisionalSessions.delete(configurationId); } + this._canvasHandoffs.delete(chat); this._chatBackings.delete(chatKey); this._chatScopes.delete(chatKey); this._chatSubagentFormats.delete(chatKey); @@ -6577,9 +6597,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(); } }); @@ -7278,7 +7304,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 4b4c576641612..1a537c9b6ecea 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3618,16 +3618,9 @@ 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._wrapper = this._register(await this._launchSdkSession()); + this._registeredByokConfig = this._wrapper.launchByokConfig; + const wrapper = this._wrapper; this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); this._canvasExtensions = undefined; this._ignoredRestoredCanvasInstanceIds.clear(); @@ -3686,6 +3679,27 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } + private async _launchSdkSession(): Promise { + let wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); + while (!this._store.isDisposed && this._requiresRestartAfterTelemetryRevocation) { + this._requiresRestartAfterTelemetryRevocation = false; + await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); + wrapper.dispose(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + 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(); + } + return wrapper; + } + /** 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(''); 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 e41c6bf847352..24f9af36bba85 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -10582,6 +10582,245 @@ suite('CopilotAgent', () => { } }); + test('revokes session telemetry and releases policy sessions when handshake lowers telemetry level', async () => { + const client = new StopCountingClient([]); + const policyDisconnects: string[] = []; + const policyDeletes: string[] = []; + client.createSession = async config => { + reportManagedSettings(config); + return { + sessionId: config?.sessionId ?? 'policy-session', + disconnect: async () => { policyDisconnects.push(config?.sessionId ?? 'policy-session'); }, + } as unknown as CopilotSession; + }; + client.deleteSession = async sessionId => { policyDeletes.push(sessionId); }; + client.rpc.mcp.discover = async () => ({ servers: [], errors: [] }); + client.rpc.catalog = { + search: async () => ({ kind: 'succeeded', searchId: 's1', candidates: [], hasMore: false }), + } as unknown as typeof client.rpc.catalog; + 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' }); + + let idleDisposed = false; + let idleDestroyed = false; + setDefaultSessionStub(agent, 'idle', { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }); + + // Simulates ProtocolServerHandler._applyClientTelemetryLevel on initialize/reconnect. + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + await timeout(0); + + assert.deepStrictEqual({ + idleDisposed, + idleDestroyed, + policyDisconnects: policyDisconnects.length, + policyDeletes: policyDeletes.length, + }, { + idleDisposed: true, + idleDestroyed: true, + policyDisconnects: 1, + policyDeletes: 1, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('revalidates in-flight session launches when telemetry is revoked during SDK launch for send and history paths', async () => { + for (const pathKind of ['send', 'history'] as const) { + const client = new StopCountingClient([]); + const launchTelemetryFlags: (boolean | undefined)[] = []; + const disconnectedSessions: number[] = []; + const firstLaunchStarted = new DeferredPromise(); + const allowFirstLaunchFinish = new DeferredPromise(); + let launchAttempt = 0; + + const handleLaunch = async (config: ResumeSessionConfig | undefined) => { + const attempt = ++launchAttempt; + reportManagedSettings(config); + launchTelemetryFlags.push(config?.enableSessionTelemetry); + if (attempt === 1) { + firstLaunchStarted.complete(); + await allowFirstLaunchFinish.p; + } + const mockSession = new MockCopilotSession(`sess-${pathKind}`); + 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 { agent } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + useRealResumePath: true, + }); + const sessionUri = AgentSession.uri('copilotcli', `sess-${pathKind}`); + const chatUri = defaultChatUri(sessionUri); + const workingDirectory = URI.file('/workspace'); + + 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 firstLaunchStarted.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowFirstLaunchFinish.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 firstLaunchStarted.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowFirstLaunchFinish.complete(); + await op; + } + + assert.deepStrictEqual({ + pathKind, + launchTelemetryFlags, + disconnectedSessions, + }, { + pathKind, + launchTelemetryFlags: [undefined, false], + disconnectedSessions: [1], + }); + } finally { + allowFirstLaunchFinish.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'); + setDefaultSessionStub(agent, sessionId, { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + takeCanvasProjection: () => handoff, + }, 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(); From c769c5128449a99813c5632d69c050c167796996 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:21:08 -0700 Subject: [PATCH 3/5] test updates --- .../node/copilot/copilotSessionLauncher.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 33 ++++++++++--------- .../test/node/copilotSessionLauncher.test.ts | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 0d880cda86ca9..036b34bae55ce 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -817,7 +817,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, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { } async createCustomizationPolicySession(client: CopilotClient, sessionId: string, workingDirectory: string, githubCredentials: CopilotGitHubSessionCredentials): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 24f9af36bba85..2037dc10625a4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -10511,19 +10511,20 @@ suite('CopilotAgent', () => { let idleDisposed = false; let idleDestroyed = false; - setDefaultSessionStub(agent, 'idle', { + const idle = { requiresRestartAfterTelemetryRevocation: false, - markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, dispose() { idleDisposed = true; }, async destroySession() { idleDestroyed = true; }, - }, idleChatUri); + }; + setDefaultSessionStub(agent, 'idle', idle, idleChatUri); let busyDisposed = false; let busyDestroyed = false; const busy = { hasActiveTurn: true, requiresRestartAfterTelemetryRevocation: false, - markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + markTelemetryRevocationRequiresRestart() { busy.requiresRestartAfterTelemetryRevocation = true; }, dispose() { busyDisposed = true; }, async destroySession() { busyDestroyed = true; }, }; @@ -10594,10 +10595,10 @@ suite('CopilotAgent', () => { } as unknown as CopilotSession; }; client.deleteSession = async sessionId => { policyDeletes.push(sessionId); }; - client.rpc.mcp.discover = async () => ({ servers: [], errors: [] }); - client.rpc.catalog = { + (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 }), - } as unknown as typeof client.rpc.catalog; + }; const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ telemetryLevel: TelemetryLevel.USAGE, appenders: [], @@ -10608,16 +10609,17 @@ suite('CopilotAgent', () => { }); try { await agent.authenticate('https://api.github.com', 'token'); - await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test' }); + await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test', limit: 10 }); let idleDisposed = false; let idleDestroyed = false; - setDefaultSessionStub(agent, 'idle', { + const idle = { requiresRestartAfterTelemetryRevocation: false, - markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + 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); @@ -10648,7 +10650,7 @@ suite('CopilotAgent', () => { const allowFirstLaunchFinish = new DeferredPromise(); let launchAttempt = 0; - const handleLaunch = async (config: ResumeSessionConfig | undefined) => { + const handleLaunch = async (config: Parameters[1]) => { const attempt = ++launchAttempt; reportManagedSettings(config); launchTelemetryFlags.push(config?.enableSessionTelemetry); @@ -10760,11 +10762,12 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - setDefaultSessionStub(agent, sessionId, { + const initialSession = { requiresRestartAfterTelemetryRevocation: false, - markTelemetryRevocationRequiresRestart() { this.requiresRestartAfterTelemetryRevocation = true; }, + markTelemetryRevocationRequiresRestart() { initialSession.requiresRestartAfterTelemetryRevocation = true; }, takeCanvasProjection: () => handoff, - }, chatUri); + }; + setDefaultSessionStub(agent, sessionId, initialSession, chatUri); telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); await timeout(0); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 04a206452829d..50e1ba7febae5 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -155,7 +155,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin withTraceContext: (_context: undefined, fn: () => T): T => fn(), } as unknown as IAgentHostOTelService, sessionOpenTelemetry, - telemetryService, + telemetryService ?? ({ telemetryLevel: TelemetryLevel.USAGE } as ITelemetryService), ); } From 1b495f31042b07482ee6632d4701132fdfdb5130 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:54:47 -0700 Subject: [PATCH 4/5] few updates --- .../agentHost/node/copilot/copilotAgent.ts | 4 +- .../node/copilot/copilotAgentSession.ts | 337 ++++++++++-------- .../agentHost/test/node/copilotAgent.test.ts | 199 +++++++---- 3 files changed, 311 insertions(+), 229 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index a73d37acebb76..34d9262bf0980 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1070,8 +1070,9 @@ export class CopilotAgent extends Disposable implements IAgent { getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), this._getGitHubSessionCredentials(), ); + const telemetryEnabledAtStart = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); let session = await createSession(); - if (this._sessionTelemetryRevoked && !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { + if (telemetryEnabledAtStart && !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { try { await session.disconnect(); } catch { @@ -7274,6 +7275,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(); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 1a537c9b6ecea..e7f73a40514fb 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'; @@ -1533,6 +1533,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(); @@ -3618,34 +3619,6 @@ export class CopilotAgentSession extends Disposable { if (this._store.isDisposed) { throw new CancellationError(); } - this._wrapper = this._register(await this._launchSdkSession()); - this._registeredByokConfig = this._wrapper.launchByokConfig; - const wrapper = this._wrapper; - 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._ownerSessionUri.toString())) { return; @@ -3653,25 +3626,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 @@ -3679,25 +3635,102 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } - private async _launchSdkSession(): Promise { - let wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); + /** + * 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; - await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); - wrapper.dispose(); + 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 = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); + 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(); } - // 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. + } + + private async _relaunchSdkWrapperForTelemetryRevocation(): Promise { + await this._disconnectAndClearWrapper(); if (this._store.isDisposed) { - wrapper.dispose(); throw new CancellationError(); } - return wrapper; + 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. */ @@ -7321,11 +7354,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); } @@ -7389,7 +7422,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; @@ -7432,7 +7465,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')) { @@ -7499,7 +7532,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')) { @@ -7567,15 +7600,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; @@ -7598,7 +7631,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onPermissionCompleted(e => { + store.add(wrapper.onPermissionCompleted(e => { const toolCallId = e.data.toolCallId; if (!toolCallId) { return; @@ -7622,7 +7655,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')) { @@ -7877,7 +7910,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}`); @@ -8102,7 +8135,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; @@ -8110,7 +8143,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') { @@ -8144,7 +8177,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) { @@ -8231,7 +8264,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')) { @@ -8277,7 +8310,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; @@ -8313,15 +8346,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}`); @@ -8333,7 +8366,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onTurnStart(e => { + store.add(wrapper.onTurnStart(e => { if (!e.agentId || !e.data.model) { return; } @@ -8344,7 +8377,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionError(e => { + store.add(wrapper.onSessionError(e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('session.error')) { return; } @@ -8380,7 +8413,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)); })); @@ -8390,7 +8423,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; } @@ -8454,7 +8487,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; @@ -8649,7 +8682,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; } @@ -8703,7 +8736,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) { @@ -8817,7 +8850,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')) { @@ -8830,7 +8863,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`. @@ -8853,14 +8886,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') { @@ -8890,16 +8923,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(); })); @@ -9323,24 +9356,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)); } /** @@ -9348,11 +9381,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; @@ -9834,13 +9867,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; @@ -9853,35 +9886,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; } @@ -9893,7 +9926,7 @@ export class CopilotAgentSession extends Disposable { ); })); - this._register(wrapper.onCanvasOpened(e => { + store.add(wrapper.onCanvasOpened(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId)) { return; } @@ -9904,14 +9937,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; } @@ -9927,15 +9960,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; @@ -9955,14 +9988,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. @@ -9996,46 +10029,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) @@ -10047,15 +10080,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); @@ -10105,20 +10138,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(); @@ -10129,7 +10162,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(); @@ -10150,7 +10183,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})`); })); @@ -10188,17 +10221,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) { @@ -10224,37 +10257,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/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2037dc10625a4..86b3cd2b90538 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -57,7 +57,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, AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } 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'; @@ -10585,10 +10585,12 @@ suite('CopilotAgent', () => { 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'); }, @@ -10625,14 +10627,20 @@ suite('CopilotAgent', () => { 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, }); @@ -10641,86 +10649,125 @@ suite('CopilotAgent', () => { } }); - test('revalidates in-flight session launches when telemetry is revoked during SDK launch for send and history paths', async () => { - for (const pathKind of ['send', 'history'] as const) { - const client = new StopCountingClient([]); - const launchTelemetryFlags: (boolean | undefined)[] = []; - const disconnectedSessions: number[] = []; - const firstLaunchStarted = new DeferredPromise(); - const allowFirstLaunchFinish = new DeferredPromise(); - let launchAttempt = 0; - - const handleLaunch = async (config: Parameters[1]) => { - const attempt = ++launchAttempt; - reportManagedSettings(config); - launchTelemetryFlags.push(config?.enableSessionTelemetry); - if (attempt === 1) { - firstLaunchStarted.complete(); - await allowFirstLaunchFinish.p; - } - const mockSession = new MockCopilotSession(`sess-${pathKind}`); - const origDisconnect = mockSession.disconnect.bind(mockSession); - mockSession.disconnect = async () => { - disconnectedSessions.push(attempt); - return origDisconnect(); + 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; }; - return mockSession as unknown as CopilotSession; - }; - client.createSession = config => handleLaunch(config); - client.resumeSession = (_id, config) => handleLaunch(config); + 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 { agent } = createTestAgentContext(disposables, { - copilotClient: client, - telemetryService, - sessionDataService, - useRealResumePath: true, - }); - const sessionUri = AgentSession.uri('copilotcli', `sess-${pathKind}`); - const chatUri = defaultChatUri(sessionUri); - const workingDirectory = URI.file('/workspace'); + 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 firstLaunchStarted.p; - telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); - allowFirstLaunchFinish.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(); + 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; } - await agent.materializeChat(chatUri, exactChatContext(sessionUri, chatUri, sessionUri), undefined); - const op = agent.chats.getMessages(chatUri, exactChatContext(sessionUri, chatUri, sessionUri)); - await firstLaunchStarted.p; - telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); - allowFirstLaunchFinish.complete(); - await op; - } - assert.deepStrictEqual({ - pathKind, - launchTelemetryFlags, - disconnectedSessions, - }, { - pathKind, - launchTelemetryFlags: [undefined, false], - disconnectedSessions: [1], - }); - } finally { - allowFirstLaunchFinish.complete(); - await disposeAgent(agent); + 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); + } } } }); From 7bbf5f5e1ee89374c72e24105d1ef0158f5a8c86 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:20:37 -0700 Subject: [PATCH 5/5] Fix MCP Start after telemetry session teardown (#341661) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 8 ++- .../agentHost/test/node/copilotAgent.test.ts | 69 +++++++++++++++++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e22ee8405f4f5..b11c3595bdfc6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2509,12 +2509,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 }, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f0df345d66ea6..4b9b801a674e7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -16747,7 +16747,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'); @@ -16767,7 +16767,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); @@ -16800,8 +16800,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'); @@ -16833,7 +16833,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, @@ -16868,6 +16868,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 });