diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 68c8082ceefb6..af8e5c71056d5 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -114,6 +114,7 @@ the deadline remain missing; the marker is not proof that a turn completed. | Host `timeToFirstProgress`, `timeToFirstSubstantiveProgress` | Turn start to existing first visible/substantive progress boundaries; absent if not observed | | Host `sendStageWorkingDirectoryMs`, `sendStageModelSelectionMs`, `sendStageAttachmentsMs`, `sendStageContributionsMs` | Elapsed time in each existing pre-send stage that ran; an interrupted open stage retains its partial duration | | Host `sendStageCheckpointMs` | **Residual critical-path wait** for the checkpoint after overlap with earlier preparation, not the entire checkpoint operation | +| Host `sendStageProviderPreparationMs` | **Residual critical-path wait** for provider turn preparation (`IAgentChats.prepareTurn`, enabled by `chat.agentHost.experimental.overlapProviderPreparation`) after it overlapped the earlier pre-send stages and the checkpoint capture; absent when preparation did not run. Provider stage marks made during preparation precede dispatch and are not reported as `providerStage*Ms` | | Host `providerStageQueueMs`, `providerStageClientMs`, `providerStageSnapshotMs`, `providerStageConfigMs`, `providerStageCreateMs`, `providerStageFinalizeMs`, `providerStagePersistMs`, `providerStageRefreshMs`, `providerStageTurnPrepareMs`, `providerStageModelResponseMs` | Sequential provider-marked stages between provider dispatch and first progress (chat queue wait, SDK client acquisition, customization snapshot, session config, SDK create/resume, post-create setup, session registration/persistence, live-session refresh, per-turn preparation, and SDK send until first progress). Only stages the provider ran are present; a turn ending before first progress retains its partial open stage. Copilot marks all of them; other providers currently mark none | | Host `hostRootTurnOrdinal`, `hostProcessAgeMs`, `titleGenerationStrategy` | Existing root ordinal and process age captured at turn start, and effective `activeAgent`, `utility`, or `deferred` strategy when observed | | Renderer `requestId` | Exact client request ID, duplicated as `turnId` for joins | diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index d1fe7c820f65d..33f732ee8f766 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -827,6 +827,16 @@ export interface IAgentPrepareChatResult { * the provider needs the owning session or storage scope. */ export interface IAgentChats { + /** + * Optional pre-send preparation, such as materializing a deferred provider + * session, after the turn's model/agent selection and working directories + * are resolved. The host may run it concurrently with the turn-start + * checkpoint capture and awaits both before {@link sendMessage} for the same + * `turnId`, so implementations must not send a prompt or modify the working + * tree. Preparation that is not needed must resolve without doing work. + */ + prepareTurn?(chat: URI, turnId: string, workingDirectories: readonly URI[] | undefined, context: AgentChatOperationContext): Promise; + /** Prepare an existing chat for input without sending a turn. May acquire its native writer lock. */ prepareChat?(chat: URI, context: AgentChatOperationContext): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 06af6c383dca8..fc4bca0cc42aa 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -570,6 +570,14 @@ export const AgentHostArtifactToolsConfigKey = 'artifactTools'; /** Root config key controlling automatic pull request association for the checked-out branch. */ export const AgentHostAutoAttachPullRequestsConfigKey = 'autoAttachPullRequests'; +/** + * Root config key forwarded from the renderer's + * `chat.agentHost.experimental.overlapProviderPreparation` setting. When + * `true`, a provider that implements `IAgentChats.prepareTurn` prepares its + * session while the turn-start checkpoint is captured, instead of after it. + */ +export const AgentHostOverlapProviderPreparationConfigKey = 'overlapProviderPreparation'; + // Root config key forwarded from the renderer when the `chat.agentSessions.migrateLegacyCopilotCli` // setting changes. When `true`, `listSessions` surfaces un-adopted extension-host Copilot CLI // sessions as adoptable agent-host sessions, and opening one adopts it in place. Experimental; off. @@ -929,6 +937,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.autoAttachPullRequests.description', "Whether the Agent Host automatically discovers and associates a pull request for the currently checked-out branch. When disabled, only pull requests recorded as artifacts or explicitly associated by session actions are considered."), default: true, }), + [AgentHostOverlapProviderPreparationConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.overlapProviderPreparation.title', "Overlap Provider Preparation"), + description: localize('agentHost.config.overlapProviderPreparation.description', "Whether agents prepare their session for a turn while the turn-start checkpoint is captured, instead of after it."), + default: false, + }), [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.migrateLegacyCopilotCliEnabled.title', "Migrate Legacy Copilot CLI Sessions"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index fd5a2ae0978b0..3329eb09ea27f 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -25,6 +25,7 @@ import { AgentHostCodexAgentCodexHomeSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostMarkdownPlanRichLinksEnabledSettingId, + AgentHostOverlapProviderPreparationSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelCaptureIdentitySettingId, AgentHostOTelDbSpanExporterEnabledSettingId, @@ -49,6 +50,7 @@ import { AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, + AgentHostOverlapProviderPreparationConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostWorkspaceSnapshotEnabledConfigKey, } from './agentHostSchema.js'; @@ -206,6 +208,15 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto' }, agentHost: { key: AgentHostMarkdownPlanRichLinksEnabledConfigKey }, }, + [AgentHostOverlapProviderPreparationSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.experimental.overlapProviderPreparation', "When enabled, agents prepare their session for a new turn, such as starting the runtime session, while the turn-start checkpoint is captured instead of after it. This can shorten the time to the first response, especially on the first turn of a session."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostOverlapProviderPreparationConfigKey }, + }, [AgentHostWorkspaceSnapshotEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.experimental.workspaceSnapshot', "When enabled, the first turn of a new Copilot agent host chat includes a bounded file-name snapshot of its working directories, so the agent can orient itself without listing the workspace first. File contents are never included."), diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index eb64d6ed849c4..287a363cca1e5 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -59,6 +59,13 @@ export type AgentHostTurnSendStage = | 'attachments' /** Running the outgoing-turn chat contributions. */ | 'contributions' + /** + * Waiting for the provider's turn preparation (`IAgentChats.prepareTurn`). + * Preparation is started earlier and runs alongside the stages above and + * the checkpoint capture, so this measures only the time it still costs + * the critical path — not the preparation's total cost. + */ + | 'providerPreparation' /** * Waiting for the turn-start checkpoint. The capture is started earlier and * runs alongside the stages above, so this measures only the time it still diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3004df7f6d798..4006c3bc42d9d 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -135,6 +135,9 @@ export const ArtifactToolsSettingId = 'chat.artifactTools.enabled'; /** Configuration key controlling automatic pull request association for the checked-out branch. */ export const AgentHostAutoAttachPullRequestsSettingId = 'chat.agentHost.experimental.autoAttachPullRequests'; +/** Configuration key controlling whether providers prepare a turn alongside the turn-start checkpoint. */ +export const AgentHostOverlapProviderPreparationSettingId = 'chat.agentHost.experimental.overlapProviderPreparation'; + /** * Configuration key gating multiple-working-directory support for the Copilot * agent-host provider. When `true`, the Copilot provider advertises the diff --git a/src/vs/platform/agentHost/common/otel/agentHostTiming.ts b/src/vs/platform/agentHost/common/otel/agentHostTiming.ts index 66645f36fdaee..66f1c801e1155 100644 --- a/src/vs/platform/agentHost/common/otel/agentHostTiming.ts +++ b/src/vs/platform/agentHost/common/otel/agentHostTiming.ts @@ -44,6 +44,7 @@ export interface IAgentHostTurnTimingDiagnostic { sendStageModelSelectionMs?: number; sendStageAttachmentsMs?: number; sendStageContributionsMs?: number; + sendStageProviderPreparationMs?: number; sendStageCheckpointMs?: number; providerStageQueueMs?: number; providerStageClientMs?: number; @@ -90,7 +91,7 @@ export function agentHostTimingAttributes(diagnostic: IAgentHostTurnTimingDiagno if (diagnostic.titleGenerationStrategy !== undefined) { put('titleGenerationStrategy', diagnostic.titleGenerationStrategy); } - for (const key of ['totalTime', 'timeToProviderDispatch', 'timeToFirstProgress', 'timeToFirstSubstantiveProgress', 'sendStageWorkingDirectoryMs', 'sendStageModelSelectionMs', 'sendStageAttachmentsMs', 'sendStageContributionsMs', 'sendStageCheckpointMs', 'providerStageQueueMs', 'providerStageClientMs', 'providerStageSnapshotMs', 'providerStageConfigMs', 'providerStageCreateMs', 'providerStageFinalizeMs', 'providerStagePersistMs', 'providerStageRefreshMs', 'providerStageTurnPrepareMs', 'providerStageModelResponseMs', 'hostRootTurnOrdinal', 'hostProcessAgeMs'] as const) { + for (const key of ['totalTime', 'timeToProviderDispatch', 'timeToFirstProgress', 'timeToFirstSubstantiveProgress', 'sendStageWorkingDirectoryMs', 'sendStageModelSelectionMs', 'sendStageAttachmentsMs', 'sendStageContributionsMs', 'sendStageProviderPreparationMs', 'sendStageCheckpointMs', 'providerStageQueueMs', 'providerStageClientMs', 'providerStageSnapshotMs', 'providerStageConfigMs', 'providerStageCreateMs', 'providerStageFinalizeMs', 'providerStagePersistMs', 'providerStageRefreshMs', 'providerStageTurnPrepareMs', 'providerStageModelResponseMs', 'hostRootTurnOrdinal', 'hostProcessAgeMs'] as const) { measurement(key, diagnostic[key]); } } else { diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 9ab59672802f3..16e7f0f7f43c6 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -267,6 +267,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry, sendStageModelSelectionMs: number | undefined; sendStageAttachmentsMs: number | undefined; sendStageContributionsMs: number | undefined; + sendStageProviderPreparationMs: number | undefined; sendStageCheckpointMs: number | undefined; timeToProviderDispatch: number | undefined; providerStageQueueMs?: number; @@ -321,6 +322,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostEventClassificatio sendStageModelSelectionMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the host spent applying the model and agent selection on the provider before dispatching the turn.' }; sendStageAttachmentsMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the host spent resolving chat attachments before dispatching the turn.' }; sendStageContributionsMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the host spent running outgoing-turn chat contributions before dispatching the turn.' }; + sendStageProviderPreparationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the host still waited on provider turn preparation before dispatching the turn, after it overlapped the earlier pre-send stages and the checkpoint capture.' }; sendStageCheckpointMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the host spent still waiting on the turn-start checkpoint before dispatching the turn, after it overlapped the earlier pre-send stages.' }; timeToProviderDispatch: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start until the message was handed to the provider, covering all host pre-send stages.' }; providerStageQueueMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds after dispatch the provider waited behind earlier operations on the same chat, before first progress.' }; @@ -1507,6 +1509,7 @@ export class AgentHostTelemetryReporter { sendStageModelSelectionMs: report.sendStageDurationsMs?.get('modelSelection'), sendStageAttachmentsMs: report.sendStageDurationsMs?.get('attachments'), sendStageContributionsMs: report.sendStageDurationsMs?.get('contributions'), + sendStageProviderPreparationMs: report.sendStageDurationsMs?.get('providerPreparation'), sendStageCheckpointMs: report.sendStageDurationsMs?.get('checkpoint'), ...providerStages, hostRootTurnOrdinal: report.hostRootTurnOrdinal, @@ -1538,6 +1541,7 @@ export class AgentHostTelemetryReporter { sendStageModelSelectionMs: report.sendStageDurationsMs?.get('modelSelection'), sendStageAttachmentsMs: report.sendStageDurationsMs?.get('attachments'), sendStageContributionsMs: report.sendStageDurationsMs?.get('contributions'), + sendStageProviderPreparationMs: report.sendStageDurationsMs?.get('providerPreparation'), sendStageCheckpointMs: report.sendStageDurationsMs?.get('checkpoint'), timeToProviderDispatch: report.sendDispatchedMs, ...providerStages, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index bc7d73f7e9b6f..33e87a6f62ad0 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -29,6 +29,10 @@ import { isPresentationOnlyToolCall, readToolCallMeta, toToolCallMeta } from '.. import { isAgentMergeMessage } from '../common/meta/agentMergeMessageMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { logSettingExperimentTrigger } from '../../telemetry/common/experimentTrigger.js'; +import { AgentHostOverlapProviderPreparationConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostOverlapProviderPreparationSettingId } from '../common/agentService.js'; +import { CopilotCliVSCodeAssignmentContextKey } from '../common/copilotCliConfig.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js'; @@ -245,6 +249,8 @@ export class AgentSideEffects extends Disposable { private readonly _pendingSessionCustomizationPublishes = new Map>(); private readonly _pendingMcpServerStarts = new NKeyMap(); private readonly _pendingCustomizationEnablementRefreshes = new Set(); + /** Set while a turn reached the overlap experiment's divergence before the assignment context arrived. */ + private _overlapExperimentTriggerPending = false; /** * Buffers signals whose `parentToolCallId` references a subagent @@ -297,6 +303,16 @@ export class AgentSideEffects extends Disposable { hostLaunchKind: this._options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, sendTurnMessage: options => void this._sendTurnMessage(options), })); + this._register(this._agentConfigService.onDidRootConfigChange(() => { + if (this._overlapExperimentTriggerPending) { + // Deferred so that the listener installing the forwarded assignment context on telemetry runs first. + queueMicrotask(() => { + if (this._overlapExperimentTriggerPending && !this._store.isDisposed) { + this._reportOverlapExperimentTrigger(); + } + }); + } + })); this._register(this._stateManager.onDidChangeSessionConfig(e => { const previousMode = getConfiguredSessionMode(e.previous); const currentMode = getConfiguredSessionMode(e.current); @@ -2008,6 +2024,22 @@ export class AgentSideEffects extends Disposable { await Promise.all(selectionUpdates); + // A provider can prepare the turn — e.g. materialize a deferred session + // with the selection applied above — while attachments, contributions + // and the checkpoint capture run. Dispatch still waits for both, so the + // checkpoint keeps describing the tree the agent starts from. A failed + // preparation is only logged: `sendMessage` then prepares as usual and + // surfaces any error exactly as it would without the overlap. + let providerPreparation: Promise | undefined; + if (agent.chats.prepareTurn) { + this._reportOverlapExperimentTrigger(); + if (this._agentConfigService.getRootValue(platformRootSchema, AgentHostOverlapProviderPreparationConfigKey) === true) { + providerPreparation = agent.chats.prepareTurn(chatUri, turnId, resolvedWorkingDirectories, clientOperationContext).catch(err => { + this._logService.warn(`[AgentSideEffects] Turn preparation failed for ${chat}; sending will prepare again`, err); + }); + } + } + failureStage = 'sendMessage'; this._turnTracker.setCurrentStage(turnChannel, turnId, failureStage); this._turnTracker.markSendStage(turnChannel, turnId, 'attachments'); @@ -2024,6 +2056,12 @@ export class AgentSideEffects extends Disposable { await this._discardPendingTurnStartCheckpoint(checkpointCapture, sessionChannel, chatUri, turnId); return; } + if (providerPreparation) { + // Measures only what preparation still costs the critical path + // after overlapping the work above, not its total cost. + this._turnTracker.markSendStage(turnChannel, turnId, 'providerPreparation'); + await providerPreparation; + } if (checkpointCapture) { // Measures only what the checkpoint still costs the critical path // after overlapping the work above, not the capture's total cost. @@ -2088,6 +2126,18 @@ export class AgentSideEffects extends Disposable { * caller. It is the only cleanup on the failure path, where no such * cancellation discard exists. */ + /** + * Reports where overlapped and sequential provider preparation diverge. Agent host + * telemetry only carries the assignment context that ExP attributes the event by once + * the workbench has forwarded it, so until then the trigger stays pending. + */ + private _reportOverlapExperimentTrigger(): void { + this._overlapExperimentTriggerPending = typeof this._agentConfigService.getRootConfigValues?.()[CopilotCliVSCodeAssignmentContextKey] !== 'string'; + if (!this._overlapExperimentTriggerPending) { + logSettingExperimentTrigger(this._telemetryService, AgentHostOverlapProviderPreparationSettingId); + } + } + private async _discardPendingTurnStartCheckpoint(capture: Promise | undefined, sessionChannel: ProtocolURI, chatUri: URI, turnId: string): Promise { if (!capture) { return; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index fbc8c8ea08d9d..c462e6b08cd9c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -983,6 +983,8 @@ export class CopilotAgent extends Disposable implements IAgent { readonly onDidCustomizationsChange: Event; /** Per-session active client state for tools + plugin snapshot tracking. */ private readonly _activeClients = new ResourceMap(); + /** Live session -> the turn whose `prepareTurn` launched it; dropped with the session. */ + private readonly _preparedTurnLaunches = new WeakMap(); /** * Last host-published customization snapshot per configuration scope (AGENTS.md section 8b). * Updated only from host call boundaries; absence is distinct from an empty list. @@ -3755,6 +3757,7 @@ export class CopilotAgent extends Disposable implements IAgent { * Chat-addressed surface for the chats within a session. */ readonly chats: IAgentChats = { + prepareTurn: (chat: URI, turnId: string, workingDirectories: readonly URI[] | undefined, context: URI | IAgentChatContext): Promise => this._prepareTurn(chat, turnId, workingDirectories, context), createChat: (chat: URI, context: URI | IAgentChatContext, options?: IAgentCreateChatOptions): Promise => { this._noteHostCustomizations(context); return this._createChat(chat, resolveAgentChatContext(context, chat), options); @@ -4778,15 +4781,22 @@ export class CopilotAgent extends Disposable implements IAgent { context: IResolvedCopilotChatContext, entry: CopilotAgentSession, workingDirectories: readonly URI[] | undefined, - options: { readonly operation: 'sendMessage' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken }, + options: { readonly operation: 'sendMessage' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken; readonly launchedForTurn?: boolean }, ): Promise { const activeClient = this._activeClients.get(context.configurationResource); // MCP Stop still needs the queued sync to finish before it can resolve the server to stop. const waitToken = options.operation === 'sendMessage' ? options.token ?? CancellationToken.None : CancellationToken.None; - await activeClient?.pluginController.retryFailedClientSyncIfNeeded(waitToken); - const { operation, allowRestart, turnId } = options; + const { operation, allowRestart, turnId, launchedForTurn } = options; const rootsChanged = workingDirectories !== undefined && !areAdditionalWorkingDirectoriesEqual(entry.appliedAdditionalDirectories, this._additionalCustomizationDirectories(workingDirectories)); - const currentSnapshot = activeClient ? await raceCancellationError(activeClient.snapshot(context.chatKey), waitToken) : undefined; + // The turn `prepareTurn` launched for revalidates against the state already + // observed in memory: anything that changed since the launch is still caught, + // without re-awaiting the syncs and workspace scans the launch just completed. + if (!launchedForTurn) { + await activeClient?.pluginController.retryFailedClientSyncIfNeeded(waitToken); + } + const currentSnapshot = !activeClient ? undefined + : launchedForTurn ? activeClient.currentSnapshot(context.chatKey) + : await raceCancellationError(activeClient.snapshot(context.chatKey), waitToken); const structuralRestartReason = activeClient && currentSnapshot ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot), waitToken) : undefined; const currentDisabledRootMcpServers = currentSnapshot ? await raceCancellationError(this._disabledRootMcpServers(context.configurationResource, entry.sessionId, currentSnapshot), waitToken) @@ -4836,8 +4846,12 @@ export class CopilotAgent extends Disposable implements IAgent { let entry: CopilotAgentSession | undefined = current.target; const hadCachedEntry = !!entry; stageRecorder?.mark('refresh'); + // The turn `prepareTurn` launched this session for revalidates its + // configuration against the state already observed in memory. + const launchedForTurn = !!entry && turnId !== undefined && this._preparedTurnLaunches.get(entry) === turnId; if (entry) { - entry = await this._refreshSessionConfiguration(current, entry, workingDirectories, { operation: 'sendMessage', allowRestart: 'always', turnId, token }); + this._preparedTurnLaunches.delete(entry); + entry = await this._refreshSessionConfiguration(current, entry, workingDirectories, { operation: 'sendMessage', allowRestart: 'always', turnId, token, launchedForTurn }); } else { await this._activeClients.get(current.configurationResource)?.pluginController.retryFailedClientSyncIfNeeded(token); if (token.isCancellationRequested) { @@ -5548,6 +5562,30 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + /** + * Materializes or resumes a chat's SDK session ahead of its first send, so the + * host can overlap that startup with the turn-start checkpoint. A chat that + * already has a live session is left to `sendMessage`, which refreshes it. + * The launch records `turnId`, so that turn's send revalidates the configuration + * against the state already observed in memory instead of re-awaiting the + * syncs and workspace scans the launch just completed. + */ + private async _prepareTurn(chat: URI, turnId: string, workingDirectories: readonly URI[] | undefined, operationContext: URI | IAgentChatContext): Promise { + const initial = this._resolveSendChatContext(chat, operationContext); + await this._queueChat(initial.configurationId, initial.sequencerKey, 'prepareTurn', async () => { + const current = this._resolveSendChatContext(chat, operationContext); + if (current.target) { + return; + } + // Mirrors the send path, so the session is launched with the same plugin state. + await this._activeClients.get(current.configurationResource)?.pluginController.retryFailedClientSyncIfNeeded(CancellationToken.None); + const launched = await this._ensureResolvedChatSession(current, workingDirectories); + if (launched) { + this._preparedTurnLaunches.set(launched, turnId); + } + }); + } + private async _queueChatTurn(context: IResolvedCopilotChatContext, operation: 'sendMessage' | 'resumeTurn', turnId: string | undefined, task: (token: CancellationToken, enterUnboundedPhase: () => void) => Promise): Promise { if (this._isShuttingDown) { throw new CancellationError(); @@ -7230,8 +7268,6 @@ class SessionPluginController extends Disposable { /** Returns the parsed plugins currently enabled for this session, awaiting any pending sync. */ public async getAppliedPlugins(): Promise { await this._customizationEnablementService.initializeSession(this._session.toString()); - const entry = this._discoveredEntry(); - const mcpDiscovery = this._mcpDiscoveryEntry(); const [host] = await Promise.all([ this._parent.hostSync().catch(err => { this._logService.warn('[Copilot:SessionPluginController] Host customization update failed', err); @@ -7241,10 +7277,23 @@ class SessionPluginController extends Disposable { this._logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err); return client.customizations; })), - entry?.whenSettled(), - mcpDiscovery?.refresh(), + this._discoveredEntry()?.whenSettled(), + this._mcpDiscoveryEntry()?.refresh(), ]); + return this._appliedPlugins(host); + } + /** + * The plugins enabled from the state already observed in memory, without + * awaiting pending syncs or re-scanning workspace MCP configuration. + */ + public currentAppliedPlugins(): readonly ICopilotPluginInfo[] { + return this._appliedPlugins(this._parent.hostCustomizations()); + } + + private _appliedPlugins(host: readonly IResolvedCustomization[]): readonly ICopilotPluginInfo[] { + const entry = this._discoveredEntry(); + const mcpDiscovery = this._mcpDiscoveryEntry(); const resolved = this._resolveCustomizationEnablement(); const desiredByUri = new Map(resolved.customizations.map(customization => [customization.uri, customization])); const desiredById = new Map(resolved.customizations.map(customization => [customization.id, customization])); @@ -7833,11 +7882,27 @@ class ActiveClient extends Disposable { }; } + /** + * The snapshot from the state already observed in memory, without awaiting + * pending customization syncs or re-scanning workspace MCP configuration. + */ + currentSnapshot(chatKey: string): IActiveClientSnapshot { + return { + tools: this.toolsForChat(chatKey), + plugins: this.pluginController.currentAppliedPlugins(), + mcpServers: this._currentMcpServers(), + }; + } + async configuredMcpServers(): Promise { return this._getMcpServers(); } private async _getMcpServers(): Promise { + return this._currentMcpServers(); + } + + private _currentMcpServers(): AgentHostMcpServers { const servers = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey) ?? {}; return structuredClone(servers); } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b09f06752c5a7..450cfe99f707f 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -38,7 +38,10 @@ import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInteractiv import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceSnapshotEnabledConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; +import { AgentHostOverlapProviderPreparationSettingId } from '../../common/agentService.js'; +import { CopilotCliVSCodeAssignmentContextKey } from '../../common/copilotCliConfig.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, AgentHostOverlapProviderPreparationConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceSnapshotEnabledConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; @@ -683,6 +686,128 @@ suite('AgentSideEffects', () => { }); }); + suite('overlapped provider preparation', () => { + + const turnStarted = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + + function createOverlapSideEffects(checkpointService: IAgentHostCheckpointService, telemetry: ITelemetryService = NullTelemetryService): AgentSideEffects { + const workingDirectory = URI.file('/wd'); + setupSession(workingDirectory.toString()); + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async () => [workingDirectory], + }, undefined, telemetry, new FakeChangesetService(), undefined, checkpointService); + disposables.add(localSideEffects.registerProgressListener(agent)); + return localSideEffects; + } + + function setRootConfig(values: Record): void { + stateManager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: values }); + } + + test('prepares the provider alongside the turn-start checkpoint and dispatches only once both settle', async () => { + const capture = new DeferredPromise(); + const preparation = new DeferredPromise(); + const order: string[] = []; + const localSideEffects = createOverlapSideEffects({ + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async () => { + order.push('checkpoint:start'); + await capture.p; + order.push('checkpoint:end'); + }, + }); + setRootConfig({ [AgentHostOverlapProviderPreparationConfigKey]: true }); + const prepared: { turnId: string; directories: string[] | undefined }[] = []; + agent.chats.prepareTurn = async (_chat, turnId, workingDirectories) => { + prepared.push({ turnId, directories: workingDirectories?.map(directory => directory.toString()) }); + order.push('prepare:start'); + await preparation.p; + order.push('prepare:end'); + }; + + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await timeout(0); + const whileBothPending = { order: [...order], sends: agent.sendMessageCalls.length }; + preparation.complete(); + await timeout(0); + const whileCapturing = { order: [...order], sends: agent.sendMessageCalls.length }; + capture.complete(); + await waitForSendMessageCalls(1); + + assert.deepStrictEqual({ whileBothPending, whileCapturing, prepared }, { + whileBothPending: { order: ['checkpoint:start', 'prepare:start'], sends: 0 }, + whileCapturing: { order: ['checkpoint:start', 'prepare:start', 'prepare:end'], sends: 0 }, + prepared: [{ turnId: 'turn-1', directories: [URI.file('/wd').toString()] }], + }); + }); + + test('prepares only when enabled and reports the experiment trigger in both arms', async () => { + const results: Record = {}; + for (const enabled of [true, false]) { + const telemetry = new TestExperimentTriggerTelemetryService(); + const localSideEffects = createOverlapSideEffects(NULL_CHECKPOINT_SERVICE, telemetry); + setRootConfig({ [AgentHostOverlapProviderPreparationConfigKey]: enabled, [CopilotCliVSCodeAssignmentContextKey]: 'assignment-context' }); + let prepared = 0; + agent.chats.prepareTurn = async () => { prepared++; }; + const sendsBefore = agent.sendMessageCalls.length; + + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(sendsBefore + 1); + results[enabled ? 'enabled' : 'disabled'] = { prepared, sends: agent.sendMessageCalls.length - sendsBefore, triggers: telemetry.triggers }; + stateManager.removeSession(sessionUri.toString()); + } + + const trigger = [`config.${AgentHostOverlapProviderPreparationSettingId}`]; + assert.deepStrictEqual(results, { + enabled: { prepared: 1, sends: 1, triggers: trigger }, + disabled: { prepared: 0, sends: 1, triggers: trigger }, + }); + }); + + test('still sends when provider preparation fails', async () => { + const localSideEffects = createOverlapSideEffects(NULL_CHECKPOINT_SERVICE); + setRootConfig({ [AgentHostOverlapProviderPreparationConfigKey]: true }); + agent.chats.prepareTurn = async () => { throw new Error('preparation failed'); }; + + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + + assert.deepStrictEqual({ + sends: agent.sendMessageCalls.length, + error: stateManager.getChatState(defaultChatUri)?.turns.at(-1)?.state === TurnState.Error, + }, { sends: 1, error: false }); + }); + + test('holds the overlap experiment trigger until the assignment context arrives', async () => { + const telemetry = new TestExperimentTriggerTelemetryService(); + const localSideEffects = createOverlapSideEffects(NULL_CHECKPOINT_SERVICE, telemetry); + agent.chats.prepareTurn = async () => { }; + + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + const beforeContext = [...telemetry.triggers]; + setRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: 'assignment-context' }); + await timeout(0); + + assert.deepStrictEqual({ beforeContext, afterContext: telemetry.triggers }, { + beforeContext: [], + afterContext: [`config.${AgentHostOverlapProviderPreparationSettingId}`], + }); + }); + }); + test('preserves the original turn-start checkpoint identity across resume and completion', async () => { const workingDirectory = URI.file('/wd'); setupSession(workingDirectory.toString()); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 4ade18b32adeb..b42cb18e66099 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -10996,6 +10996,84 @@ suite('CopilotAgent', () => { } }); + test('prepareTurn materializes a deferred chat once, and only that turn\'s send revalidates from in-memory state', async () => { + const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); + const sessionDataService = disposables.add(new TestSessionDataService()); + let creates = 0; + client.createSession = async () => { + creates++; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'prepare-turn'); + const chat = defaultChatUri(session); + const workingDirectory = URI.file('/workspace'); + const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); + const context = exactChatContext(result.session, chat, result.session); + + await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); + const afterPrepare = { creates, live: hasLiveChat(agent, chat) }; + await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); + // Count configuration re-checks: the refresh path re-derives the client snapshot. + const activeClient = (agent as unknown as { _activeClients: { get(session: URI): { snapshot(chatKey?: string): Promise } | undefined } })._activeClients.get(result.session)!; + const snapshot = activeClient.snapshot; + let snapshots = 0; + activeClient.snapshot = chatKey => { + snapshots++; + return snapshot.call(activeClient, chatKey); + }; + await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); + const preparedTurnSnapshots = snapshots; + await agent.chats.sendMessage(chat, 'again', [workingDirectory], undefined, 'turn-2', undefined, context); + + assert.deepStrictEqual({ afterPrepare, creates, preparedTurnSnapshots, laterTurnSnapshots: snapshots - preparedTurnSnapshots }, { + afterPrepare: { creates: 1, live: true }, + creates: 1, + preparedTurnSnapshots: 0, + laterTurnSnapshots: 1, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('the prepared turn still relaunches its session when root MCP servers change before it is sent', async () => { + const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); + const sessionDataService = disposables.add(new TestSessionDataService()); + const launches: string[] = []; + client.createSession = async () => { + launches.push('create'); + return new MockCopilotSession() as unknown as CopilotSession; + }; + client.resumeSession = async () => { + launches.push('resume'); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + sessionDataService, + rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'prepare-turn-stale'); + const chat = defaultChatUri(session); + const workingDirectory = URI.file('/workspace'); + const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); + const context = exactChatContext(result.session, chat, result.session); + + await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); + configurationService.updateRootConfig({ [AgentHostMcpServersConfigKey]: { playwright: { type: 'stdio', command: 'node' } } }); + await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); + + assert.deepStrictEqual({ launches, live: hasLiveChat(agent, chat) }, { launches: ['create', 'resume'], live: true }); + } finally { + await disposeAgent(agent); + } + }); + test('restores a preflight-failed deferred chat and retries only its known-empty SDK backing', async () => { const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); const sessionDataService = disposables.add(new TestSessionDataService());