diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index a50d799db37d13..cc5ee6b72368c7 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -691,6 +691,9 @@ export interface IAgentChats { /** Dispose the addressed chat and free its backing. */ disposeChat(chat: URI, context: AgentChatOperationContext): Promise; + /** Return whether the addressed chat can currently release its in-memory backing. */ + canReleaseChat?(chat: URI, context: AgentChatOperationContext): Promise; + /** Release the addressed chat's in-memory backing without deleting durable data. */ releaseChat(chat: URI, context: AgentChatOperationContext): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts b/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts new file mode 100644 index 00000000000000..843ee56014ee32 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { AgentHostClientType } from './agentHostClientInfo.js'; +import { AgentHostClientConnectionKind } from './agentHostTelemetry.js'; + +type AgentHostStartupOutcome = 'success' | 'error' | 'timeout'; +type AgentHostStartupFailureStage = 'protocolConnection' | 'sessionList'; + +export const AgentHostStartupTimeoutMs = 2 * 60 * 1000; + +interface IAgentHostStartupEvent { + clientType: AgentHostClientType; + connectionKind: AgentHostClientConnectionKind; + outcome: AgentHostStartupOutcome; + failureStage: AgentHostStartupFailureStage | undefined; + timeToMessagePortMs: number | undefined; + timeToProtocolConnectionMs: number | undefined; + timeToAuthenticationSettledMs: number | undefined; + timeToSessionListRequestMs: number | undefined; + timeToSessionListCompleteMs: number | undefined; + sessionListDurationMs: number | undefined; + sessionListAttemptCount: number; + sessionListFailureCount: number; +} + +type AgentHostStartupClassification = { + clientType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded type of the Agent Host client.' }; + connectionKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The route the client used to reach the Agent Host.' }; + outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether Agent Host startup reached the first successful session list or failed to connect.' }; + failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded startup stage that failed, when startup did not succeed.' }; + timeToMessagePortMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the local Agent Host start request until its initial MessagePort was acquired.' }; + timeToProtocolConnectionMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until AHP initialization completed.' }; + timeToAuthenticationSettledMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the initial authentication pass settled.' }; + timeToSessionListRequestMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the first session-list request.' }; + timeToSessionListCompleteMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the first successful session-list response.' }; + sessionListDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the first session-list request until the first successful response, including retries or overlapping requests.' }; + sessionListAttemptCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of session-list requests started before the first successful response or connection failure.' }; + sessionListFailureCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of session-list requests that failed before the first successful response or connection failure.' }; + owner: 'roblourens'; + comment: 'Tracks Agent Host startup performance from the client start request through AHP connection, authentication, and the first successful session list.'; +}; + +export class AgentHostStartupTelemetry extends Disposable { + + private readonly _stopWatch; + private readonly _timeout: IDisposable; + private _reported = false; + private _timeToMessagePortMs: number | undefined; + private _timeToProtocolConnectionMs: number | undefined; + private _timeToAuthenticationSettledMs: number | undefined; + private _timeToSessionListRequestMs: number | undefined; + private _sessionListAttemptCount = 0; + private _sessionListFailureCount = 0; + + constructor( + private readonly _clientType: AgentHostClientType, + private readonly _connectionKind: AgentHostClientConnectionKind, + stopWatchFactory: () => Pick, + timeoutFactory: (callback: () => void, timeoutMs: number) => IDisposable, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + this._stopWatch = stopWatchFactory(); + this._timeout = this._register(timeoutFactory(() => this._report('timeout', this._failureStage()), AgentHostStartupTimeoutMs)); + } + + messagePortAcquired(): void { + this._timeToMessagePortMs ??= this._stopWatch.elapsed(); + } + + protocolConnected(): void { + this._timeToProtocolConnectionMs ??= this._stopWatch.elapsed(); + } + + authenticationSettled(): void { + this._timeToAuthenticationSettledMs ??= this._stopWatch.elapsed(); + } + + sessionListRequested(): void { + if (this._reported) { + return; + } + this._sessionListAttemptCount++; + this._timeToSessionListRequestMs ??= this._stopWatch.elapsed(); + } + + sessionListFailed(): void { + if (!this._reported) { + this._sessionListFailureCount++; + } + } + + sessionListSucceeded(): void { + this._report('success', undefined); + } + + connectionFailed(): void { + this._report('error', this._failureStage()); + } + + override dispose(): void { + this._reported = true; + super.dispose(); + } + + /** Startup reaches the session-list stage as soon as the protocol connects. */ + private _failureStage(): AgentHostStartupFailureStage { + return this._timeToProtocolConnectionMs === undefined ? 'protocolConnection' : 'sessionList'; + } + + private _report(outcome: AgentHostStartupOutcome, failureStage: AgentHostStartupFailureStage | undefined): void { + if (this._reported) { + return; + } + this._reported = true; + this._timeout.dispose(); + const timeToSessionListCompleteMs = outcome === 'success' ? this._stopWatch.elapsed() : undefined; + this._telemetryService.publicLog2('agentHost.startup', { + clientType: this._clientType, + connectionKind: this._connectionKind, + outcome, + failureStage, + timeToMessagePortMs: this._timeToMessagePortMs, + timeToProtocolConnectionMs: this._timeToProtocolConnectionMs, + timeToAuthenticationSettledMs: this._timeToAuthenticationSettledMs, + timeToSessionListRequestMs: this._timeToSessionListRequestMs, + timeToSessionListCompleteMs, + sessionListDurationMs: timeToSessionListCompleteMs !== undefined && this._timeToSessionListRequestMs !== undefined + ? Math.max(0, timeToSessionListCompleteMs - this._timeToSessionListRequestMs) + : undefined, + sessionListAttemptCount: this._sessionListAttemptCount, + sessionListFailureCount: this._sessionListFailureCount, + }); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index a349c70544de46..e38f6fc7020317 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DeferredPromise } from '../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { mark } from '../../../base/common/performance.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { getDelayedChannel, IChannelClient, IChannelServer, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; @@ -22,8 +23,10 @@ import { AgentHostIpcChannelTransport } from '../browser/agentHostIpcChannelTran import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; +import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js'; import { LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; +import { AgentHostStartupTelemetry } from '../common/agentHostStartupTelemetry.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId, @@ -139,6 +142,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private _didConnectInitially = false; private _didStartInitialSessionList = false; private _didCompleteInitialSessionList = false; + private _startupTelemetry: AgentHostStartupTelemetry | undefined; private readonly _onAgentHostExit = this._register(new Emitter()); readonly onAgentHostExit = this._onAgentHostExit.event; @@ -187,6 +191,13 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos startAgentHost(): void { if (!this._protocolClient) { mark('code/agentHost/willStart'); + this._startupTelemetry = this._register(this._instantiationService.createInstance( + AgentHostStartupTelemetry, + getAgentHostClientType(this._clientInfo), + AgentHostClientConnectionKind.Local, + () => StopWatch.create(true), + (callback, timeoutMs) => disposableTimeout(callback, timeoutMs), + )); this._protocolClient = this._register(this._instantiationService.createInstance( RemoteAgentHostProtocolClient, LOCAL_AGENT_HOST_RESOURCE_IDENTITY, @@ -242,6 +253,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (!this._didAcquireInitialMessagePort) { this._didAcquireInitialMessagePort = true; + this._startupTelemetry?.messagePortAcquired(); mark('code/agentHost/didAcquireMessagePort'); } this._logService.info(`${LOG_PREFIX} MessagePort acquired, creating client...`); @@ -273,6 +285,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (state === AgentHostClientState.Connected) { this._managementConnection.connected(); + this._startupTelemetry?.protocolConnected(); if (!this._didConnectInitially) { this._didConnectInitially = true; mark('code/agentHost/didConnect'); @@ -284,6 +297,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (state === AgentHostClientState.Reconnecting) { this._managementConnection.reconnecting(); } else { + this._startupTelemetry?.connectionFailed(); this._managementConnection.closed(state === AgentHostClientState.Incompatible ? 'Local agent host protocol is incompatible.' : 'Local agent host connection closed.'); @@ -305,6 +319,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (!pending) { this._authenticationSettled = true; + this._startupTelemetry?.authenticationSettled(); } this._authenticationPending.set(pending, undefined); } @@ -354,17 +369,25 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } listSessions(): Promise { + this._startupTelemetry?.sessionListRequested(); if (!this._didStartInitialSessionList) { this._didStartInitialSessionList = true; mark('code/agentHost/willListSessions'); } - return this._requireClient().listSessions().then(sessions => { - if (!this._didCompleteInitialSessionList) { - this._didCompleteInitialSessionList = true; - mark('code/agentHost/didListSessions'); - } - return sessions; - }); + return this._requireClient().listSessions().then( + sessions => { + this._startupTelemetry?.sessionListSucceeded(); + if (!this._didCompleteInitialSessionList) { + this._didCompleteInitialSessionList = true; + mark('code/agentHost/didListSessions'); + } + return sessions; + }, + error => { + this._startupTelemetry?.sessionListFailed(); + throw error; + }, + ); } createSession(config?: IAgentCreateSessionConfig): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index e32f2c136829c0..3baef2a365984c 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -54,7 +54,7 @@ export async function registerAgentHostNetworkServices( const configurationService = disposables.add(new ConfigurationService(settingsResource, fileService, policyService, logService)); await configurationService.initialize(); diServices.set(IConfigurationService, configurationService); - const proxyResolver = new AgentHostProxyResolver(configurationService, logService); + const proxyResolver = disposables.add(new AgentHostProxyResolver(configurationService, logService)); diServices.set(IAgentHostProxyResolver, proxyResolver); const requestService = disposables.add(new AgentHostRequestService(configurationService, environmentService, logService, proxyResolver)); diServices.set(IRequestService, requestService); diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts index 11dc94ee8379bf..127948f1117397 100644 --- a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { LogLevel as ProxyLogLevel, ProxyAgentParams, ProxySupportSetting, createFetchPatch, createProxyAuthorizationLookup, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent'; -import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../log/common/log.js'; @@ -26,6 +27,8 @@ export const IAgentHostProxyResolver = createDecorator( export interface IAgentHostProxyResolver { readonly _serviceBrand: undefined; + readonly onDidRegisterConnection: Event; + /** Register a renderer connection. Disposing the result removes it. */ register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable; @@ -42,10 +45,13 @@ export interface IAgentHostProxyResolver { fetch(input: string | URL | Request, init?: RequestInit): Promise; } -export class AgentHostProxyResolver implements IAgentHostProxyResolver { +export class AgentHostProxyResolver extends Disposable implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + private readonly _onDidRegisterConnection = this._register(new Emitter()); + readonly onDidRegisterConnection = this._onDidRegisterConnection.event; + private readonly _connections = new Map(); private _proxyResolver: ReturnType | undefined; private _proxyAgentParams: ProxyAgentParams | undefined; @@ -54,10 +60,16 @@ export class AgentHostProxyResolver implements IAgentHostProxyResolver { constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + const hadConnections = this._connections.size > 0; this._connections.set(clientId, connection); + if (!hadConnections) { + this._onDidRegisterConnection.fire(); + } return toDisposable(() => { if (this._connections.get(clientId) === connection) { this._connections.delete(clientId); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6a29c67c621fd0..358049a9e71e1d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2371,6 +2371,15 @@ export class AgentService extends Disposable implements IAgentService { * Idle eviction must use {@link IAgentChats.releaseChat}, not destructive * session finalization, so the session remains resumable. */ + private async _canReleaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { + for (const chat of chats) { + if (provider.chats.canReleaseChat && !await provider.chats.canReleaseChat(chat, this._chatContext(session, chat))) { + return false; + } + } + return true; + } + private async _releaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); // Still release every catalog chat if one rejects; otherwise an idle-evicted @@ -3030,16 +3039,13 @@ export class AgentService extends Disposable implements IAgentService { async subscribe(resource: URI, clientId: string): Promise { this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); const resourceStr = resource.toString(); - // Register the subscriber up front so a concurrent unsubscribe cannot - // evict the session state while we are awaiting restore. On any failure - // path below we must roll the registration back, otherwise the leaked - // refcount would permanently pin (or block eviction of) the resource. - // {@link addSubscriber} is the single point that triggers the - // uncommitted-changeset refresh on the 0→1 transition (covers both - // the cold-snapshot path here and the handshake fast-path used by - // {@link ProtocolServerHandler} when state is already cached). - this.addSubscriber(resource, clientId); try { + await this._releaseSessionInFlight.get(this._sessionReleaseKey(resource)); + // Register after an in-flight release settles so a successful release + // can evict cached state and this subscribe reconstructs it. The + // handshake fast path calls addSubscriber directly and therefore pins + // its already-returned snapshot instead. + this.addSubscriber(resource, clientId); // Check for terminal state const terminalState = this._terminalManager.getTerminalState(resourceStr); if (terminalState) { @@ -3132,6 +3138,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private _sessionReleaseKey(resource: URI): string { + const resourceString = resource.toString(); + const changesetSession = parseChangesetUri(resourceString)?.sessionUri; + const chatSession = parseDefaultChatUri(resourceString); + let session = URI.parse(changesetSession ?? chatSession ?? resourceString); + let subagent; + while ((subagent = parseSubagentSessionUri(session))) { + session = subagent.parentSession; + } + return session.toString(); + } + /** Waits for an armed subagent chat to register (or its wait to time out); returns `undefined` if not armed or never registered. */ private async _awaitPendingSubagentChat(subagentChatUri: string): Promise { const pending = this._pendingSubagentChats.get(subagentChatUri); @@ -3191,16 +3209,25 @@ export class AgentService extends Disposable implements IAgentService { // and keeps the live provider SDK session, avoiding a disconnect/resume // churn cycle that races concurrent session operations on the shared // provider runtime. A zero grace releases on the next tick. - this._pendingSessionRelease.set(resource, disposableTimeout(() => { - this._pendingSessionRelease.deleteAndDispose(resource); - void this._maybeEvictIdleSession(resource).catch(err => { - this._logService.error(err, `[AgentService] Failed to evict idle session ${resource.toString()}`); + this._scheduleSessionRelease(resource); + } + + private _cancelPendingSessionRelease(resource: URI): void { + this._pendingSessionRelease.deleteAndDispose(this._sessionReleaseResource(resource)); + } + + private _scheduleSessionRelease(resource: URI): void { + const session = this._sessionReleaseResource(resource); + this._pendingSessionRelease.set(session, disposableTimeout(() => { + this._pendingSessionRelease.deleteAndDispose(session); + void this._maybeEvictIdleSession(session).catch(err => { + this._logService.error(err, `[AgentService] Failed to evict idle session ${session.toString()}`); }); }, SESSION_RELEASE_GRACE_MS)); } - private _cancelPendingSessionRelease(resource: URI): void { - this._pendingSessionRelease.deleteAndDispose(resource); + private _sessionReleaseResource(resource: URI): URI { + return URI.parse(this._sessionReleaseKey(resource)); } /** @@ -3291,28 +3318,11 @@ export class AgentService extends Disposable implements IAgentService { */ private async _maybeEvictIdleSession(resource: URI): Promise { const key = resource.toString(); - if (this._resourceSubscribers.has(resource)) { - return; - } - // Walk up the subagent ancestry: the SDK session and its turn tree are - // owned by the root session, so eviction must target the root. - let evictionTarget = resource; - { - let parsed; - while ((parsed = parseSubagentSessionUri(evictionTarget))) { - evictionTarget = parsed.parentSession; - } - } - // Don't evict if the root or any of its subagent descendants still has subscribers. - if (this._resourceSubscribers.has(evictionTarget)) { + const evictionTarget = this._sessionReleaseResource(resource); + const evictionTargetKey = evictionTarget.toString(); + if (this._hasSessionSubscribers(evictionTarget)) { return; } - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (this._isSubagentDescendantOf(subscribedUri, evictionTarget)) { - return; - } - } - const evictionTargetKey = evictionTarget.toString(); // A restore/resume racing this unsubscribe means a client is about to // observe the session again; releasing now would tear down state that // the in-flight rehydrate is populating. @@ -3320,46 +3330,56 @@ export class AgentService extends Disposable implements IAgentService { return; } const targetState = this._stateManager.getSessionState(evictionTargetKey); - if (!targetState || targetState.activeTurn !== undefined) { + if (!targetState) { + return; + } + if (targetState.activeTurn !== undefined) { + this._scheduleSessionRelease(evictionTarget); + return; + } + if (this._releaseSessionInFlight.has(evictionTargetKey)) { return; } const chats = this._getSessionChatsInTeardownOrder(evictionTarget); await this._whenSessionDataIdle(evictionTarget); - if (this._resourceSubscribers.has(evictionTarget) || this._restoreSessionInFlight.has(evictionTargetKey)) { + if (this._hasSessionSubscribers(evictionTarget) || this._restoreSessionInFlight.has(evictionTargetKey) || this._releaseSessionInFlight.has(evictionTargetKey)) { return; } - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (this._isSubagentDescendantOf(subscribedUri, evictionTarget)) { - return; - } - } const settledState = this._stateManager.getSessionState(evictionTargetKey); if (!settledState || settledState.activeTurn !== undefined) { return; } - this._logService.info(`[AgentService] Evicting idle session: ${evictionTargetKey} (triggered by unsubscribe of ${key})`); - // Also evict any sibling subagent entries cached under the parent: their - // authoritative state is the parent's turn tree, and dropping the parent - // would leave them orphaned. - const subagentPrefix = buildSubagentSessionUriPrefix(evictionTarget); - for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { - this._stateManager.removeSession(cachedKey); - } - this._sideEffects.clearSessionTitleState(evictionTargetKey, settledState.chats.map(chat => chat.resource)); - this._stateManager.removeSession(evictionTargetKey); - // Release the provider's in-memory SDK session in lockstep with the - // cached state. Non-destructive: durable data is preserved so the - // session resumes transparently on the next access. Fire-and-forget — - // the provider sequences the release internally and re-checks its own - // invariants (e.g. a turn that started after this call). const provider = this._findProviderForSession(evictionTarget); if (!provider) { return; } - const release = this._releaseSession(provider, evictionTarget, chats); - const trackedRelease = release.catch(err => { - this._logService.error(err, `[AgentService] Failed to release idle session ${evictionTargetKey}`); - }); + const trackedRelease = (async () => { + try { + if (!await this._canReleaseSession(provider, evictionTarget, chats)) { + if (!this._hasSessionSubscribers(evictionTarget)) { + this._scheduleSessionRelease(evictionTarget); + } + return; + } + const currentState = this._stateManager.getSessionState(evictionTargetKey); + if (this._hasSessionSubscribers(evictionTarget)) { + return; + } + if (this._restoreSessionInFlight.has(evictionTargetKey) || currentState?.activeTurn !== undefined) { + this._scheduleSessionRelease(evictionTarget); + return; + } + if (currentState) { + this._evictSessionState(evictionTarget, evictionTargetKey, key, currentState.chats.map(chat => chat.resource)); + } + await this._releaseSession(provider, evictionTarget, chats); + } catch (err) { + this._logService.error(err, `[AgentService] Failed to release idle session ${evictionTargetKey}`); + if (!this._hasSessionSubscribers(evictionTarget)) { + this._scheduleSessionRelease(evictionTarget); + } + } + })(); this._releaseSessionInFlight.set(evictionTargetKey, trackedRelease); void trackedRelease.then(() => { if (this._releaseSessionInFlight.get(evictionTargetKey) === trackedRelease) { @@ -3368,6 +3388,26 @@ export class AgentService extends Disposable implements IAgentService { }); } + private _hasSessionSubscribers(session: URI): boolean { + const sessionKey = this._sessionReleaseKey(session); + for (const subscribedUri of this._resourceSubscribers.keys()) { + if (this._sessionReleaseKey(subscribedUri) === sessionKey) { + return true; + } + } + return false; + } + + private _evictSessionState(evictionTarget: URI, evictionTargetKey: string, triggerKey: string, chats: readonly string[]): void { + this._logService.info(`[AgentService] Evicting idle session: ${evictionTargetKey} (triggered by unsubscribe of ${triggerKey})`); + const subagentPrefix = buildSubagentSessionUriPrefix(evictionTarget); + for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { + this._stateManager.removeSession(cachedKey); + } + this._sideEffects.clearSessionTitleState(evictionTargetKey, chats); + this._stateManager.removeSession(evictionTargetKey); + } + // Returns true when a changeset is safe to drop from the in-memory cache. private _isChangesetEvictable(changeset: string): boolean { const changesetUri = URI.parse(changeset); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index a76508cb4282e7..3dfc29ee36905a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -662,11 +662,9 @@ export class CopilotAgent extends Disposable implements IAgent { private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; private _clientStopping: Promise | undefined; - /** - * Proxy URL injected into the running client's subprocess env (`undefined` - * when none was injected). Used to detect when a token change alters the - * token-discovered CAPI endpoint's proxy so we can restart the client. - */ + private _resolvedProxy: string | undefined; + private _proxyRefresh: Promise | undefined; + private _proxyResolutionGeneration = 0; private _appliedProxy: string | undefined; /** * Reasons for a client restart that is parked until every chat is idle. See @@ -676,6 +674,7 @@ export class CopilotAgent extends Disposable implements IAgent { private _closedConnectionRecovery: { readonly clientFailureId: string; readonly promise: Promise } | undefined; private readonly _reportedClientFailures = new WeakSet(); private readonly _authenticationSequencer = new Sequencer(); + private _updatingGitHubCredentials = false; private _githubToken: string | undefined; private _serverToolHost: IAgentServerToolHost | undefined; @@ -792,6 +791,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._githubTelemetryRouter = isAgentHostTelemetryService(this._telemetryService) ? new AgentHostGitHubTelemetryRouter(this._telemetryService) : undefined; + this._register(this._proxyResolver.onDidRegisterConnection(() => this._refreshProxy())); this.onDidCustomizationsChange = this._plugins.onDidChange; // Mirror host-owned titles under the SDK conversation id used by the agent's turn spans. this._register(sessionTitleSignal.onDidChangeSessionTitle(({ provider, session, title }) => { @@ -939,6 +939,7 @@ export class CopilotAgent extends Disposable implements IAgent { const enterpriseHost = this._getEnterpriseHost(); const systemProxyEnabled = this._isSystemProxyEnabled(); const managedSettingsPermissions = this._managedSettingsService.permissions; + const proxyTargetChanged = this._lastEnterpriseHost !== enterpriseHost || this._lastSystemProxyEnabled !== systemProxyEnabled; if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck && this._lastCopilotSdkLogLevelSetting === copilotSdkLogLevelSetting && this._lastEnterpriseHost === enterpriseHost && this._lastSystemProxyEnabled === systemProxyEnabled && equals(this._lastManagedSettingsPermissions, managedSettingsPermissions)) { return; } @@ -956,6 +957,9 @@ export class CopilotAgent extends Disposable implements IAgent { this._lastEnterpriseHost = enterpriseHost; this._lastSystemProxyEnabled = systemProxyEnabled; this._lastManagedSettingsPermissions = managedSettingsPermissions; + if (proxyTargetChanged) { + this._refreshProxy(); + } if (this._client) { this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); } @@ -991,6 +995,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (!this._client) { return; } + if (this._updatingGitHubCredentials) { + this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until GitHub credential updates finish`); + return; + } const busyChats = this._chatsWithActiveTurn(); if (busyChats > 0) { this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until ${busyChats} in-flight turn(s) finish`); @@ -1005,7 +1013,7 @@ export class CopilotAgent extends Disposable implements IAgent { * to go idle drives this again. */ private async _applyPendingClientRestart(): Promise { - if (this._pendingClientRestartReasons.size === 0 || this._shutdownPromise || !this._client || this._chatsWithActiveTurn() > 0) { + if (this._pendingClientRestartReasons.size === 0 || this._shutdownPromise || !this._client || this._updatingGitHubCredentials || this._chatsWithActiveTurn() > 0) { return; } const reason = [...this._pendingClientRestartReasons].join('; '); @@ -1371,6 +1379,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`); this._githubToken = token; this._updateRestrictedTelemetry(token); + this._refreshProxy(); if (!token) { await this._requestClientRestart('GitHub authentication cleared'); void this._scheduleModelRefresh(); @@ -1378,24 +1387,28 @@ export class CopilotAgent extends Disposable implements IAgent { } const host = this._gitHubEndpointService.getEnterpriseUri() ?? 'https://github.com'; let restartRequired = false; - for (const session of this._allLiveSessions()) { - try { - const result = await session.updateGitHubCredentials(host, token); - if (!result.success) { + this._updatingGitHubCredentials = true; + try { + for (const session of this._allLiveSessions()) { + try { + const result = await session.updateGitHubCredentials(host, token); + if (!result.success) { + restartRequired = true; + this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`); + } else if (result.copilotUserResolved === false) { + this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`); + } + } catch (error) { restartRequired = true; - this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`); - } else if (result.copilotUserResolved === false) { - this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`); + this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`); } - } catch (error) { - restartRequired = true; - this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`); } + } finally { + this._updatingGitHubCredentials = false; + await this._applyPendingClientRestart(); } if (restartRequired) { await this._requestClientRestart('GitHub credential update failed'); - } else { - await this._restartClientIfProxyChanged(); } await this._resolveCopilotSku(token); void this._scheduleModelRefresh(); @@ -1742,6 +1755,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._clientStarting) { return this._clientStarting; } + if (!this._proxyRefresh) { + this._refreshProxy(); + } // Snapshot the startup config so we can detect a change that lands while the // client is still starting and abort the stale start (the values are baked // into the client options / subprocess env below). @@ -1760,7 +1776,7 @@ export class CopilotAgent extends Disposable implements IAgent { // deliberately never reach the runtime; an ambient value here would // re-introduce a process-wide alias for every session behind its back. delete env['COPILOT_MODEL_FAMILY']; - await this._configureProxyEnv(env); + this._applyProxyEnv(env); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can @@ -2443,6 +2459,7 @@ export class CopilotAgent extends Disposable implements IAgent { return this._createChat(chat, resolveAgentChatContext(context, chat), options); }, disposeChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._disposeChat(chatUri, context), + canReleaseChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._canReleaseChat(chatUri, context), releaseChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._releaseChat(chatUri, context), sendMessage: (chatUri: URI, prompt: string, workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise => { const workingDirectories = Array.isArray(workingDirectoriesOrDirectory) ? workingDirectoriesOrDirectory : workingDirectoriesOrDirectory ? [workingDirectoriesOrDirectory] : undefined; @@ -3662,6 +3679,21 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private async _canReleaseChat(chat: URI, operationContext: URI | IAgentChatContext): Promise { + const target = this._resolveChatContext(chat, operationContext).target; + if (!target) { + return true; + } + if (target.hasActiveTurn) { + return false; + } + if (await target.hasRunningDetachedShells()) { + this._logService.info(`[Copilot:${target.sessionId}] Deferring idle release while a detached shell is running`); + return false; + } + return true; + } + private async _releaseChat(chat: URI, operationContext: URI | IAgentChatContext): Promise { const initial = this._resolveChatContext(chat, operationContext); const lifetime = this._getOrCreateSessionLifetime(initial.sdkSessionId ?? initial.configurationId); @@ -3784,15 +3816,20 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } const parentEntry = this._findSessionBySdkId(configurationId); - const workingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory + const persistedWorkingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory ?? (await this._readSessionMetadata(configurationResource)).workingDirectory; - if (!workingDirectory) { + if (!persistedWorkingDirectory) { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } + const workingDirectory = await this._configurationService.resolveWorkingDirectoryForResume(configurationResource.toString(), persistedWorkingDirectory); + const launchWorkingDirectories = workingDirectories + ? [workingDirectory, ...workingDirectories.slice(1)] + : undefined; const client = await this._ensureClient(); const activeClient = this._getOrCreateActiveClient(configurationResource, workingDirectory); + activeClient.pluginController.reanchor(workingDirectory); const snapshot = await activeClient.snapshot(chatKey); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { @@ -3800,7 +3837,7 @@ export class CopilotAgent extends Disposable implements IAgent { client, sessionId: info.sdkSessionId, workingDirectory, - additionalDirectories: workingDirectories?.slice(1), + additionalDirectories: launchWorkingDirectories?.slice(1), resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, snapshot, disabledRootMcpServers: this._disabledRootMcpServers(configurationResource, info.sdkSessionId, snapshot), @@ -3813,8 +3850,8 @@ export class CopilotAgent extends Disposable implements IAgent { await agentSession.initializeSession(); this._throwIfClientReplaced(client, agentSession); this._registerLiveChat(chat, agentSession, activeClient); - if (workingDirectories) { - await this._storeSessionMetadata(context.resource, info.model, workingDirectory, workingDirectories, undefined, undefined); + if (launchWorkingDirectories) { + await this._storeSessionMetadata(context.resource, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); } this._logService.info(`[Copilot] Resumed chat backing ${chatKey} for configuration ${configurationResource.toString()}`); return agentSession; @@ -3996,8 +4033,8 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ - private async _configureProxyEnv(env: Record): Promise { - const proxy = await this._resolveProxyForSdk(env); + private _applyProxyEnv(env: Record): void { + const proxy = this._isSystemProxyEnabled() ? this._resolvedProxy : undefined; this._appliedProxy = proxy; if (proxy) { for (const key of COPILOT_PROXY_SET_ENV_KEYS) { @@ -4036,31 +4073,38 @@ export class CopilotAgent extends Disposable implements IAgent { } } - /** - * Restarts the client when token-based CAPI endpoint discovery changes its - * subprocess proxy. Session credential updates otherwise keep the process alive. - */ - private async _restartClientIfProxyChanged(): Promise { - if (!this._client && !this._clientStarting) { - return; - } - const oldProxy = this._appliedProxy; - const newProxy = await this._resolveProxyForSdk(); - if (newProxy === oldProxy) { - return; - } - if (this._clientStarting) { - try { - await this._clientStarting; - } catch { + private _refreshProxy(): void { + const generation = ++this._proxyResolutionGeneration; + const refresh = this._resolveProxyForSdk().then(async proxy => { + if (generation !== this._proxyResolutionGeneration) { return; } - } - if (!this._client) { - return; - } - this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`); - await this._requestClientRestart('CAPI proxy changed after GitHub token update'); + this._resolvedProxy = proxy; + const effectiveProxy = this._isSystemProxyEnabled() ? proxy : undefined; + if (effectiveProxy === this._appliedProxy) { + return; + } + if (this._clientStarting) { + try { + await this._clientStarting; + } catch { + return; + } + // A newer proxy resolution (or the client start we just awaited) + // may have already superseded this one; re-check both so we don't + // restart based on a stale comparison. + if (generation !== this._proxyResolutionGeneration || effectiveProxy === this._appliedProxy) { + return; + } + } + await this._requestClientRestart(`CAPI proxy changed (${this._appliedProxy ?? '(none)'} -> ${effectiveProxy ?? '(none)'})`); + }).catch(error => this._logService.error('[Copilot] Failed to refresh CAPI proxy', error)); + this._proxyRefresh = refresh; + void refresh.finally(() => { + if (this._proxyRefresh === refresh) { + this._proxyRefresh = undefined; + } + }); } private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index c0f14f1d5fdbce..e897e99f70c699 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1252,6 +1252,19 @@ export class CopilotAgentSession extends Disposable { this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); } + async hasRunningDetachedShells(): Promise { + try { + await this._wrapper.session.rpc.tasks.refresh(); + const tasks = await this._wrapper.session.rpc.tasks.list(); + return tasks.tasks.some(task => task.type === 'shell' + && task.attachmentMode === 'detached' + && (task.status === 'running' || task.status === 'idle')); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to read detached shell state; deferring release: ${getErrorMessage(err)}`); + return true; + } + } + /** Refreshes prompt-cache state and the session-wide nano-AIU total from the SDK's authoritative usage metrics. */ private async _refreshSessionUsageMetrics(): Promise { try { diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 13d676fe8cfdd5..72df994010e23c 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -8,10 +8,25 @@ import { IChannelClient, IChannelServer, IServerChannel } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { NullLogService } from '../../../log/common/log.js'; +import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostStartupTelemetry } from '../../common/agentHostStartupTelemetry.js'; +import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import { LocalAgentHostManagementConnection, registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { eventName: string; data: ITelemetryData | undefined }[] = []; + + override publicLog2(eventName?: string, data?: ITelemetryData): void { + if (eventName) { + this.events.push({ eventName, data }); + } + } +} + /** * Regression coverage for the renderer reverse-RPC channel registration. The * BYOK language-model bridge depends on `IAgentHostByokLmHandler`, registered by @@ -106,3 +121,179 @@ suite('registerAgentHostClientChannels', () => { assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_PROXY_CHANNEL]); }); }); + +suite('AgentHostStartupTelemetry', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports the first successful session list with startup milestones and retry counts', () => { + let now = 0; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + now = 10; + tracker.messagePortAcquired(); + now = 20; + tracker.sessionListRequested(); + now = 50; + tracker.protocolConnected(); + now = 80; + tracker.authenticationSettled(); + now = 85; + tracker.sessionListRequested(); + now = 90; + tracker.sessionListFailed(); + now = 120; + tracker.sessionListSucceeded(); + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'success', + failureStage: undefined, + timeToMessagePortMs: 10, + timeToProtocolConnectionMs: 50, + timeToAuthenticationSettledMs: 80, + timeToSessionListRequestMs: 20, + timeToSessionListCompleteMs: 120, + sessionListDurationMs: 100, + sessionListAttemptCount: 2, + sessionListFailureCount: 1, + }, + }]); + }); + + test('reports a protocol connection failure once', () => { + let now = 30; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.EditorWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + tracker.connectionFailed(); + now = 40; + tracker.sessionListRequested(); + tracker.sessionListSucceeded(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'editor_window', + connectionKind: 'local', + outcome: 'error', + failureStage: 'protocolConnection', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: undefined, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: undefined, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 0, + sessionListFailureCount: 0, + }, + }]); + }); + + test('attributes a terminal connection failure after connecting to the session-list stage', () => { + let now = 50; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + tracker.protocolConnected(); + now = 70; + tracker.sessionListRequested(); + now = 90; + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'error', + failureStage: 'sessionList', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: 50, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: 70, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 1, + sessionListFailureCount: 0, + }, + }]); + }); + + test('reports a session-list timeout after the protocol connected', () => { + let onTimeout = () => { }; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => 120_000 }), + callback => { + onTimeout = callback; + return { dispose() { } }; + }, + telemetryService, + )); + + tracker.protocolConnected(); + tracker.sessionListRequested(); + tracker.sessionListFailed(); + onTimeout(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'timeout', + failureStage: 'sessionList', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: 120_000, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: 120_000, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 1, + sessionListFailureCount: 1, + }, + }]); + }); + + test('does not report a connection failure after disposal', () => { + const telemetryService = new TestTelemetryService(); + const tracker = new AgentHostStartupTelemetry( + AgentHostClientType.EditorWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => 10 }), + () => ({ dispose() { } }), + telemetryService, + ); + + tracker.dispose(); + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, []); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts index e8eef9cf8b7d56..37c2a43be888d0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts @@ -17,12 +17,13 @@ import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { AuthInfo, IRequestService } from '../../../request/common/request.js'; import { AgentHostClientProxyChannel, createAgentHostClientProxyConnection, type IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; -import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentHostRequestService } from '../../node/agentHostRequestService.js'; import { NetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; class TestProxyResolver implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + readonly onDidRegisterConnection = Event.None; lastInput: string | URL | Request | undefined; lastInit: RequestInit | undefined; @@ -43,6 +44,35 @@ class TestProxyResolver implements IAgentHostProxyResolver { } } +suite('AgentHostProxyResolver', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires when the first connection registers and after all connections reconnect', () => { + const resolver = disposables.add(new AgentHostProxyResolver(new TestConfigurationService(), new NullLogService())); + let registrations = 0; + disposables.add(resolver.onDidRegisterConnection(() => registrations++)); + const connection: IAgentHostClientProxyConnection = { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + }; + + const first = disposables.add(resolver.register('first', connection)); + const afterFirst = registrations; + const second = disposables.add(resolver.register('second', connection)); + const afterSecond = registrations; + first.dispose(); + second.dispose(); + disposables.add(resolver.register('third', connection)); + + assert.deepStrictEqual({ afterFirst, afterSecond, afterReconnect: registrations }, { + afterFirst: 1, + afterSecond: 1, + afterReconnect: 2, + }); + }); +}); + suite('AgentHostRequestService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a65799a1357b0c..9efd41365ffdbc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9325,6 +9325,36 @@ suite('AgentService (node dispatcher)', () => { } } + class DeferringReleaseMockAgent extends MockAgent { + releaseAttempts = 0; + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + canReleaseChat: async () => { + this.releaseAttempts++; + return this.releaseAttempts !== 1; + }, + releaseChat: (chat, context) => base.releaseChat(chat, context), + })); + } + + class DelayedCanReleaseMockAgent extends MockAgent { + readonly canRelease = new DeferredPromise(); + readonly events: string[] = []; + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + canReleaseChat: async () => { + this.events.push('canRelease:start'); + await this.canRelease.p; + this.events.push('canRelease:end'); + return true; + }, + releaseChat: async (chat, context) => { + this.events.push('release'); + await base.releaseChat(chat, context); + }, + })); + } + test('an empty session created in this lifetime stays observable until GC fires', async () => { service.registerProvider(copilotAgent); const sessionResource = await service.createSession({ provider: 'copilot' }); @@ -9357,6 +9387,104 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); + test('a provider can defer idle release without losing cached state', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DeferringReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + service.addSubscriber(session, 'client-1'); + service.unsubscribe(session, 'client-1'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 1, + hasCachedState: true, + }); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 2, + hasCachedState: false, + }); + }); + }); + + test('chat subscription cancels the root release retry and gets a fresh grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DeferringReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + const chatResource = URI.parse(buildDefaultChatUri(session)); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + service.addSubscriber(session, 'client-session'); + service.unsubscribe(session, 'client-session'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.strictEqual(agent.releaseAttempts, 1); + + service.addSubscriber(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 10_000)); + service.unsubscribe(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 20_000)); + assert.strictEqual(agent.releaseAttempts, 1, 'the cancelled root retry must not fire at its original deadline'); + await new Promise(resolve => setTimeout(resolve, 9_999)); + assert.strictEqual(agent.releaseAttempts, 1, 'chat disconnect should receive a fresh release grace'); + await new Promise(resolve => setTimeout(resolve, 1)); + + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 2, + hasCachedState: false, + }); + }); + }); + + test('overlapping root release timers preserve the original in-flight release', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DelayedReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + const chatResource = URI.parse(buildDefaultChatUri(session)); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + agent.events.length = 0; + service.addSubscriber(session, 'client-session'); + service.unsubscribe(session, 'client-session'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual(agent.events, ['release:start']); + + service.addSubscriber(chatResource, 'client-chat'); + service.unsubscribe(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual(agent.events, ['release:start'], 'second timer must not start another provider release'); + + await agent.release.complete(); + await Promise.resolve(); + assert.deepStrictEqual(agent.events, ['release:start', 'release:end']); + }); + }); + test('a restored idle session is evicted when its last subscriber drops', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { service.registerProvider(copilotAgent); @@ -9478,7 +9606,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('restore waits for provider release to finish', () => { + test('subscription waits for provider release and restores evicted state', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const agent = new DelayedReleaseMockAgent('copilot'); service.registerProvider(agent); @@ -9496,10 +9624,51 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 30_000)); - const restore = service.subscribe(sessionResource, 'client-2'); + let subscriptionSettled = false; + const subscription = service.subscribe(sessionResource, 'client-2').then(result => { + subscriptionSettled = true; + return result; + }); + await Promise.resolve(); + assert.strictEqual(subscriptionSettled, false); await agent.release.complete(); - await restore; - assert.deepStrictEqual(agent.events, ['release:start', 'release:end', 'metadata']); + await subscription; + assert.deepStrictEqual({ + events: agent.events, + hasCachedState: service.stateManager.getSessionState(sessionResource.toString()) !== undefined, + }, { + events: ['release:start', 'release:end', 'metadata'], + hasCachedState: true, + }); + }); + }); + + test('initial subscriber added during release preflight keeps cached state', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DelayedCanReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + agent.events.length = 0; + service.addSubscriber(session, 'client-1'); + service.unsubscribe(session, 'client-1'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + + service.addSubscriber(session, 'client-2'); + await agent.canRelease.complete(); + await Promise.resolve(); + + assert.deepStrictEqual({ + events: agent.events, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + events: ['canRelease:start', 'canRelease:end'], + hasCachedState: true, + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 272450ff57f23a..98b028bdccff20 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -13,7 +13,7 @@ import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; import { autorun, observableValue, waitForState } from '../../../../base/common/observable.js'; @@ -670,15 +670,29 @@ class RecordingReleaseOTelService implements IAgentHostOTelService { class TestProxyResolver implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + private readonly _onDidRegisterConnection = new Emitter(); + readonly onDidRegisterConnection = this._onDidRegisterConnection.event; + private readonly _connections = new Map(); resolveProxyCalls = 0; resolvedProxy: string | undefined; + resolveProxyGate: Promise | undefined; - register(_clientId: string, _connection: IAgentHostClientProxyConnection): IDisposable { - return Disposable.None; + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + const hadConnections = this._connections.size > 0; + this._connections.set(clientId, connection); + if (!hadConnections) { + this._onDidRegisterConnection.fire(); + } + return toDisposable(() => { + if (this._connections.get(clientId) === connection) { + this._connections.delete(clientId); + } + }); } async resolveProxy(_url: string): Promise { this.resolveProxyCalls++; + await this.resolveProxyGate; return this.resolvedProxy; } @@ -695,6 +709,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostManagedSettingsService managedSettingsService: IAgentHostManagedSettingsService, + @IAgentHostGitHubEndpointService gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostOTelService otelService: IAgentHostOTelService, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCustomizationEnablementService customizationEnablementService: ICustomizationEnablementService, @@ -704,7 +719,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, createTestGitHubEndpointService(), otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); + super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); } protected override _createCopilotClient(): CopilotClient { @@ -731,6 +746,7 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostManagedSettingsService managedSettingsService: IAgentHostManagedSettingsService, + @IAgentHostGitHubEndpointService gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostOTelService otelService: IAgentHostOTelService, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCustomizationEnablementService customizationEnablementService: ICustomizationEnablementService, @@ -740,7 +756,7 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, createTestGitHubEndpointService(), otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); + super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); } protected override _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -771,6 +787,7 @@ class TestableCopilotAgent extends CopilotAgent { appliedSnapshot: undefined, dispose: fake.dispose, onDidRequireAuth: Event.None, + hasRunningDetachedShells: async () => false, resetTurnState: (newTurnId: string) => { turnId = newTurnId; }, emitInitialMarkdown: (content: string) => { emitter.fire({ @@ -1803,6 +1820,75 @@ suite('CopilotAgent', () => { } }); + test('defers a proxy-change restart until credential updates finish', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxyResolutionGate = new DeferredPromise(); + const credentialUpdateStarted = new DeferredPromise(); + const credentialUpdateGate = new DeferredPromise(); + const agent = createTestAgent(disposables, { copilotClient: client, proxyResolver }); + const session = { + hasActiveTurn: false, + disposed: false, + disposedBeforeUpdateCompleted: false, + async updateGitHubCredentials() { + credentialUpdateStarted.complete(); + await credentialUpdateGate.p; + this.disposedBeforeUpdateCompleted = this.disposed; + return { success: true }; + }, + dispose() { this.disposed = true; }, + } satisfies ICredentialUpdateSession & { disposed: boolean; disposedBeforeUpdateCompleted: boolean }; + const pendingRestartCount = () => (agent as unknown as { _pendingClientRestartReasons: Set })._pendingClientRestartReasons.size; + try { + await agent.listChatsToMigrate(); + setDefaultSessionStub(agent, 'proxy-change-during-credentials', session); + proxyResolver.resolvedProxy = 'http://new-proxy:8080'; + proxyResolver.resolveProxyGate = proxyResolutionGate.p; + + const authentication = agent.authenticate('https://api.github.com', 'fresh-token'); + await credentialUpdateStarted.p; + proxyResolutionGate.complete(); + for (let i = 0; i < 20 && pendingRestartCount() === 0 && client.stopCallCount === 0; i++) { + await timeout(0); + } + const duringUpdate = { + stops: client.stopCallCount, + disposed: session.disposed, + pendingRestarts: pendingRestartCount(), + }; + + credentialUpdateGate.complete(); + await authentication; + + assert.deepStrictEqual({ + duringUpdate, + disposedBeforeUpdateCompleted: session.disposedBeforeUpdateCompleted, + afterUpdate: { + stops: client.stopCallCount, + disposed: session.disposed, + pendingRestarts: pendingRestartCount(), + }, + }, { + duringUpdate: { + stops: 0, + disposed: false, + pendingRestarts: 1, + }, + disposedBeforeUpdateCompleted: false, + afterUpdate: { + stops: 1, + disposed: true, + pendingRestarts: 0, + }, + }); + } finally { + proxyResolutionGate.complete(); + credentialUpdateGate.complete(); + await disposeAgent(agent); + } + }); + test('defers a proxy-change restart until an active turn ends', async () => { const client = new TestCopilotClient([]); const proxyResolver = new TestProxyResolver(); @@ -2772,6 +2858,340 @@ suite('CopilotAgent', () => { } }); + test('does not block client startup on system proxy resolution', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const resolveProxyGate = new DeferredPromise(); + proxyResolver.resolvedProxy = 'http://system-proxy.example:8080'; + proxyResolver.resolveProxyGate = resolveProxyGate.p; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const startup = agent.listChatsToMigrate(); + let proxyResolutionCompleted = false; + try { + await startup; + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + }, { + startCallCount: 1, + resolveProxyCalls: 1, + httpProxy: undefined, + }); + + resolveProxyGate.complete(); + proxyResolutionCompleted = true; + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 2, + httpProxy: proxyResolver.resolvedProxy, + httpsProxy: proxyResolver.resolvedProxy, + }); + } finally { + if (!proxyResolutionCompleted) { + resolveProxyGate.complete(); + } + await startup; + await disposeAgent(agent); + } + }); + + test('does not restart for a proxy resolution superseded while the client starts', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const resolveProxyGate = new DeferredPromise(); + const startGate = new DeferredPromise(); + const firstProxy = 'http://stale-system-proxy.example:8080'; + proxyResolver.resolvedProxy = firstProxy; + proxyResolver.resolveProxyGate = resolveProxyGate.p; + client.startGate = startGate.p; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const proxyState = agent as unknown as { + _resolvedProxy: string | undefined; + _refreshProxy(): void; + }; + const startup = agent.listChatsToMigrate(); + try { + for (let i = 0; i < 20 && client.startCallCount < 1; i++) { + await timeout(0); + } + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + }, { + startCallCount: 1, + resolveProxyCalls: 1, + }); + + resolveProxyGate.complete(); + for (let i = 0; i < 20 && proxyState._resolvedProxy !== firstProxy; i++) { + await timeout(0); + } + assert.strictEqual(proxyState._resolvedProxy, firstProxy); + + proxyResolver.resolvedProxy = undefined; + proxyResolver.resolveProxyGate = undefined; + proxyState._refreshProxy(); + for (let i = 0; i < 20 && proxyState._resolvedProxy !== undefined; i++) { + await timeout(0); + } + assert.strictEqual(proxyState._resolvedProxy, undefined); + + startGate.complete(); + await startup; + await timeout(0); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + }, { + startCallCount: 1, + stopCallCount: 0, + resolveProxyCalls: 2, + }); + } finally { + resolveProxyGate.complete(); + startGate.complete(); + await startup; + await disposeAgent(agent); + } + }); + + test('forwards a system proxy resolved when the bridge registers before client startup', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + proxyResolver.resolvedProxy = 'http://system-proxy.example:8080'; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 1, + resolveProxyCalls: 2, + httpProxy: proxyResolver.resolvedProxy, + httpsProxy: proxyResolver.resolvedProxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('resolves the proxy when system proxy support is enabled after construction', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxy = 'http://enabled-system-proxy.example:8080'; + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + proxyResolver, + rootConfig: { [AgentHostSystemProxyEnabledConfigKey]: false }, + }); + try { + await agent.listChatsToMigrate(); + proxyResolver.resolvedProxy = proxy; + configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: true }); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 1, + httpProxy: proxy, + httpsProxy: proxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('refreshes the proxy when the enterprise host changes', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const endpointChange = disposables.add(new Emitter()); + let enterpriseUri: string | undefined; + const currentEndpointService = () => createTestGitHubEndpointService(enterpriseUri); + const endpointService = { + _serviceBrand: undefined, + onDidChange: endpointChange.event, + getApiBaseUri: () => currentEndpointService().getApiBaseUri(), + getGraphQlUri: () => currentEndpointService().getGraphQlUri(), + getEnterpriseHost: () => currentEndpointService().getEnterpriseHost(), + getEnterpriseUri: () => currentEndpointService().getEnterpriseUri(), + getCopilotResource: () => currentEndpointService().getCopilotResource(), + getRepoResource: () => currentEndpointService().getRepoResource(), + } satisfies IAgentHostGitHubEndpointService; + const initialProxy = 'http://github-proxy.example:8080'; + const enterpriseProxy = 'http://enterprise-proxy.example:8080'; + proxyResolver.resolvedProxy = initialProxy; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver, gitHubEndpointService: endpointService }); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + proxyResolver.resolvedProxy = enterpriseProxy; + enterpriseUri = 'https://github.example.com'; + endpointChange.fire(); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 3, + httpProxy: enterpriseProxy, + httpsProxy: enterpriseProxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('resolves the proxy on first client start without a bridge', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxy = 'http://late-system-proxy.example:8080'; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + try { + await timeout(0); + proxyResolver.resolvedProxy = proxy; + await agent.listChatsToMigrate(); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 1, + httpProxy: proxy, + httpsProxy: proxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('refreshes the cached proxy without blocking every fresh client start', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const secondResolutionGate = new DeferredPromise(); + const initialProxy = 'http://initial-system-proxy.example:8080'; + const changedProxy = 'http://changed-system-proxy.example:8080'; + proxyResolver.resolvedProxy = initialProxy; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const stopClient = () => (agent as unknown as { _stopClient(): Promise })._stopClient(); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + await stopClient(); + + proxyResolver.resolvedProxy = changedProxy; + proxyResolver.resolveProxyGate = secondResolutionGate.p; + await agent.listChatsToMigrate(); + const duringResolution = { + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }; + + secondResolutionGate.complete(); + for (let i = 0; i < 20 && client.stopCallCount < 2; i++) { + await timeout(0); + } + proxyResolver.resolveProxyGate = undefined; + await agent.listChatsToMigrate(); + await timeout(0); + + assert.deepStrictEqual({ + duringResolution, + afterResolution: { + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, + }, { + duringResolution: { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 3, + httpProxy: initialProxy, + httpsProxy: initialProxy, + }, + afterResolution: { + startCallCount: 3, + stopCallCount: 2, + resolveProxyCalls: 4, + httpProxy: changedProxy, + httpsProxy: changedProxy, + }, + }); + } finally { + secondResolutionGate.complete(); + await disposeAgent(agent); + } + }); + test('passes the configured log level to the Copilot SDK client', async () => { const client = new TestCopilotClient([]); const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); @@ -6796,6 +7216,7 @@ suite('CopilotAgent', () => { resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, async setModel(id: string, reasoningEffort?: string, contextTier?: string): Promise { rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier }); }, async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + async hasRunningDetachedShells(): Promise { return false; }, handleClientToolCallComplete(): void { }, async getNextTurnEventId(): Promise { return undefined; }, getMessages: getMessages ?? (async () => []), @@ -7076,6 +7497,8 @@ suite('CopilotAgent', () => { setPeerChatStub(agent, chat, { workingDirectory: URI.file('/workspace'), hasActiveTurn: false, + async hasRunningDetachedShells() { return false; }, + async getMessages() { return []; }, async destroySession() { releaseStarted = true; await releaseGate.p; @@ -7183,6 +7606,7 @@ suite('CopilotAgent', () => { setPeerChatStub(agent, waitingChat, { workingDirectory: URI.file('/workspace'), hasActiveTurn: false, + async hasRunningDetachedShells() { return false; }, async destroySession() { releaseStarted = true; await releaseGate.p; @@ -7605,6 +8029,65 @@ suite('CopilotAgent', () => { } }); + test('sendMessage resolves the working directory before resuming an addressed backing', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, configurationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([]), + rootConfig: { [AgentHostCopilotMultiRootEnabledConfigKey]: true }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'route-resolved-peer'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const persistedWorkingDirectory = URI.file('/missing-worktree'); + const resolvedWorkingDirectory = URI.file('/repository'); + const secondaryWorkingDirectory = URI.file('/secondary'); + const resolveCalls: { session: string; workingDirectory: string }[] = []; + configurationService.resolveWorkingDirectoryForResume = async (session, workingDirectory) => { + resolveCalls.push({ session, workingDirectory: workingDirectory.toString() }); + return resolvedWorkingDirectory; + }; + await provisionSession(agent, { session, workingDirectories: [persistedWorkingDirectory, secondaryWorkingDirectory] }); + await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); + + const internals = agent as unknown as ChatInternals; + const launches: { workingDirectory: string | undefined; additionalDirectories: string[] | undefined; customizationDirectory: string | undefined }[] = []; + internals._createAgentSession = (launchPlan, customizationDirectory, _activeClient, identity) => { + launches.push({ + workingDirectory: launchPlan.workingDirectory?.toString(), + additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), + customizationDirectory: customizationDirectory?.toString(), + }); + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + return built.fake; + }; + + await agent.chats.sendMessage(chatUri, 'hello peer', [persistedWorkingDirectory, secondaryWorkingDirectory], undefined, undefined, undefined, exactChatContext(session, chatUri, session)); + const dbRef = sessionDataService.openDatabase(session); + const storedWorkingDirectories = await dbRef.object.getMetadata('copilot.workingDirectories'); + dbRef.dispose(); + + assert.deepStrictEqual({ + resolveCalls, + launches, + storedWorkingDirectories: storedWorkingDirectories ? JSON.parse(storedWorkingDirectories) : undefined, + }, { + resolveCalls: [{ session: session.toString(), workingDirectory: persistedWorkingDirectory.toString() }], + launches: [{ + workingDirectory: resolvedWorkingDirectory.toString(), + additionalDirectories: [secondaryWorkingDirectory.toString()], + customizationDirectory: resolvedWorkingDirectory.toString(), + }], + storedWorkingDirectories: [resolvedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()], + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage throws for a chat with no backing chat', async () => { const agent = createTestAgent(disposables); try { @@ -7922,6 +8405,7 @@ suite('CopilotAgent', () => { async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, async abort(): Promise { rec.aborted++; }, async getMessages(): Promise { return [{ id: `turn-${key}` } as unknown as Turn]; }, + async hasRunningDetachedShells(): Promise { return false; }, handleClientToolCallComplete(): void { }, dispose(): void { rec.disposed = true; }, } as unknown as CopilotAgentSession; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 60002591224ea6..f86e33c0393f97 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -138,6 +138,10 @@ class MockCopilotSession { * Lets a test make an earlier-issued read resolve after a later one. */ readonly usageMetricsGates: Array> = []; + backgroundTasks: Awaited>['tasks'] = []; + backgroundTaskListCalls = 0; + backgroundTaskRefreshCalls = 0; + backgroundTaskListError: Error | undefined; private readonly _handlers = new Map void>>(); private readonly _allHandlers = new Set(); @@ -279,6 +283,22 @@ class MockCopilotSession { return this.commandInvokeResult; }, }, + tasks: { + list: async () => { + this.backgroundTaskListCalls++; + if (this.backgroundTaskListError) { + const error = this.backgroundTaskListError; + this.backgroundTaskListError = undefined; + throw error; + } + const tasks = this.backgroundTasks.map(task => ({ ...task })); + return { tasks }; + }, + refresh: async () => { + this.backgroundTaskRefreshCalls++; + return {}; + }, + }, mcp: { list: async () => { if (this.mcpListError !== undefined) { @@ -5795,6 +5815,68 @@ suite('CopilotAgentSession', () => { assert.ok(isAction(signals[0], ActionType.ChatTurnComplete)); }); + test('idle event completes the active turn while a detached shell runs', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.backgroundTasks = [{ + type: 'shell', + id: 'shell-1', + description: 'Monitor CI', + status: 'running', + startedAt: new Date(0).toISOString(), + command: 'monitor-ci', + attachmentMode: 'detached', + executionMode: 'background', + }]; + session.resetTurnState('turn-background'); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + hasActiveTurn: session.hasActiveTurn, + completedTurns: getActions(signals).filter(action => action.type === ActionType.ChatTurnComplete).length, + listCalls: mockSession.backgroundTaskListCalls, + refreshCalls: mockSession.backgroundTaskRefreshCalls, + }, { + hasActiveTurn: false, + completedTurns: 1, + listCalls: 0, + refreshCalls: 0, + }); + }); + + test('running detached shell state defers release conservatively', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const runningShell = { + type: 'shell' as const, + id: 'shell-running', + description: 'Monitor CI', + status: 'running' as const, + startedAt: new Date(0).toISOString(), + command: 'monitor-ci', + attachmentMode: 'detached' as const, + executionMode: 'background' as const, + }; + mockSession.backgroundTasks = [runningShell]; + const running = await session.hasRunningDetachedShells(); + mockSession.backgroundTasks = [{ ...runningShell, status: 'completed', completedAt: new Date().toISOString() }]; + const completed = await session.hasRunningDetachedShells(); + mockSession.backgroundTaskListError = new Error('transient tasks.list failure'); + const failedRead = await session.hasRunningDetachedShells(); + + assert.deepStrictEqual({ + running, + completed, + failedRead, + listCalls: mockSession.backgroundTaskListCalls, + refreshCalls: mockSession.backgroundTaskRefreshCalls, + }, { + running: true, + completed: false, + failedRead: true, + listCalls: 3, + refreshCalls: 3, + }); + }); + test('tool-call aggregate emits once with cancelled result across abort and idle', async () => { const telemetryService = new CapturingTelemetryService(); const { session, mockSession } = await createAgentSession(disposables, { diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index b991fe3f371ce8..7d2c6dbf5e55f1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -469,6 +469,23 @@ A capture that genuinely cannot be refreshed goes in `STALE_RECORDED_REQUEST_EXC Remove the entry from `STALE_RECORDED_REQUEST_EXCEPTIONS` and re-record once the fork defect is fixed. ## Suspected product bugs +### Resource reads ignore the requested base64 encoding + +A client can request arbitrary file bytes from the Agent Host in base64 so binary data remains lossless. The host always reports UTF-8 instead, and bytes that are not valid UTF-8 cannot be reconstructed by the client. + +- Test: `resourceRead returns requested base64 content without byte loss`. +- Scope: conformance reference provider on all platforms. +- Expected: AHP `resourceRead` honors `encoding: "base64"` and returns all requested bytes with `encoding: "base64"`. +- Observed: the response reports `encoding: "utf-8"` and stringifies the raw bytes as text. +- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`. +- Reproduce: + + ```bash + AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \ + --grep "resourceRead returns requested base64 content without byte loss" + ``` + ### Branch changeset stays stale after a second edit to the same file - Test: `a second edit updates one changeset entry in place`. diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index a60d0e8548c39a..6863ae43bd48b4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,47 +15,47 @@ }, "total": { "statements": { - "covered": 83077, - "total": 109466, - "percentage": 75.89 + "covered": 86322, + "total": 113954, + "percentage": 75.75 }, "branches": { - "covered": 9300, - "total": 13964, - "percentage": 66.59 + "covered": 9917, + "total": 14872, + "percentage": 66.68 }, "functions": { - "covered": 3027, - "total": 4252, - "percentage": 71.19 + "covered": 3230, + "total": 4520, + "percentage": 71.46 }, "lines": { - "covered": 83077, - "total": 109466, - "percentage": 75.89 + "covered": 86322, + "total": 113954, + "percentage": 75.75 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1112, - "total": 1120, - "percentage": 99.28 + "covered": 1149, + "total": 1159, + "percentage": 99.13 }, "branches": { - "covered": 26, - "total": 33, - "percentage": 78.78 + "covered": 27, + "total": 35, + "percentage": 77.14 }, "functions": { - "covered": 9, - "total": 10, - "percentage": 90 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "lines": { - "covered": 1112, - "total": 1120, - "percentage": 99.28 + "covered": 1149, + "total": 1159, + "percentage": 99.13 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -148,8 +148,8 @@ }, "src/vs/platform/agentHost/common/agentHostChangesetService.ts": { "statements": { - "covered": 305, - "total": 305, + "covered": 306, + "total": 306, "percentage": 100 }, "branches": { @@ -163,8 +163,8 @@ "percentage": 100 }, "lines": { - "covered": 305, - "total": 305, + "covered": 306, + "total": 306, "percentage": 100 } }, @@ -259,8 +259,8 @@ "src/vs/platform/agentHost/common/agentHostConfigurationSync.ts": { "statements": { "covered": 72, - "total": 143, - "percentage": 50.34 + "total": 148, + "percentage": 48.64 }, "branches": { "covered": 0, @@ -274,8 +274,8 @@ }, "lines": { "covered": 72, - "total": 143, - "percentage": 50.34 + "total": 148, + "percentage": 48.64 } }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { @@ -303,8 +303,8 @@ "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { "statements": { "covered": 128, - "total": 142, - "percentage": 90.14 + "total": 141, + "percentage": 90.78 }, "branches": { "covered": 1, @@ -318,8 +318,8 @@ }, "lines": { "covered": 128, - "total": 142, - "percentage": 90.14 + "total": 141, + "percentage": 90.78 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { @@ -373,9 +373,9 @@ "percentage": 95.08 }, "branches": { - "covered": 17, - "total": 21, - "percentage": 80.95 + "covered": 16, + "total": 20, + "percentage": 80 }, "functions": { "covered": 5, @@ -456,14 +456,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 679, - "total": 775, - "percentage": 87.61 + "covered": 711, + "total": 807, + "percentage": 88.1 }, "branches": { - "covered": 48, - "total": 67, - "percentage": 71.64 + "covered": 49, + "total": 68, + "percentage": 72.05 }, "functions": { "covered": 15, @@ -471,9 +471,9 @@ "percentage": 68.18 }, "lines": { - "covered": 679, - "total": 775, - "percentage": 87.61 + "covered": 711, + "total": 807, + "percentage": 88.1 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -500,24 +500,24 @@ }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 68, - "total": 81, - "percentage": 83.95 + "covered": 85, + "total": 106, + "percentage": 80.18 }, "branches": { - "covered": 5, - "total": 13, - "percentage": 38.46 + "covered": 8, + "total": 19, + "percentage": 42.1 }, "functions": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 5, + "total": 7, + "percentage": 71.42 }, "lines": { - "covered": 68, - "total": 81, - "percentage": 83.95 + "covered": 85, + "total": 106, + "percentage": 80.18 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -610,9 +610,9 @@ }, "src/vs/platform/agentHost/common/agentModelPricing.ts": { "statements": { - "covered": 179, - "total": 282, - "percentage": 63.47 + "covered": 178, + "total": 281, + "percentage": 63.34 }, "branches": { "covered": 4, @@ -625,9 +625,9 @@ "percentage": 37.5 }, "lines": { - "covered": 179, - "total": 282, - "percentage": 63.47 + "covered": 178, + "total": 281, + "percentage": 63.34 } }, "src/vs/platform/agentHost/common/agentModelSource.ts": { @@ -676,9 +676,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1010, - "total": 1175, - "percentage": 85.95 + "covered": 1016, + "total": 1181, + "percentage": 86.02 }, "branches": { "covered": 9, @@ -691,9 +691,9 @@ "percentage": 12.5 }, "lines": { - "covered": 1010, - "total": 1175, - "percentage": 85.95 + "covered": 1016, + "total": 1181, + "percentage": 86.02 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { @@ -725,9 +725,9 @@ "percentage": 80.07 }, "branches": { - "covered": 28, - "total": 38, - "percentage": 73.68 + "covered": 25, + "total": 35, + "percentage": 71.42 }, "functions": { "covered": 11, @@ -769,9 +769,9 @@ "percentage": 86.48 }, "branches": { - "covered": 49, - "total": 61, - "percentage": 80.32 + "covered": 48, + "total": 60, + "percentage": 80 }, "functions": { "covered": 19, @@ -918,24 +918,24 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 159, - "total": 159, - "percentage": 100 + "covered": 214, + "total": 223, + "percentage": 95.96 }, "branches": { - "covered": 8, - "total": 15, - "percentage": 53.33 + "covered": 7, + "total": 13, + "percentage": 53.84 }, "functions": { - "covered": 3, + "covered": 2, "total": 3, - "percentage": 100 + "percentage": 66.66 }, "lines": { - "covered": 159, - "total": 159, - "percentage": 100 + "covered": 214, + "total": 223, + "percentage": 95.96 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -982,6 +982,28 @@ "percentage": 72.22 } }, + "src/vs/platform/agentHost/common/customizationEnablement.ts": { + "statements": { + "covered": 73, + "total": 120, + "percentage": 60.83 + }, + "branches": { + "covered": 8, + "total": 8, + "percentage": 100 + }, + "functions": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "lines": { + "covered": 73, + "total": 120, + "percentage": 60.83 + } + }, "src/vs/platform/agentHost/common/diffComputeService.ts": { "statements": { "covered": 53, @@ -1202,6 +1224,28 @@ "percentage": 39.68 } }, + "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "statements": { + "covered": 20, + "total": 30, + "percentage": 66.66 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 2, + "percentage": 0 + }, + "lines": { + "covered": 20, + "total": 30, + "percentage": 66.66 + } + }, "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { "statements": { "covered": 27, @@ -1248,9 +1292,9 @@ }, "src/vs/platform/agentHost/common/openSessionLink.ts": { "statements": { - "covered": 108, - "total": 124, - "percentage": 87.09 + "covered": 116, + "total": 151, + "percentage": 76.82 }, "branches": { "covered": 12, @@ -1259,13 +1303,13 @@ }, "functions": { "covered": 4, - "total": 8, - "percentage": 50 + "total": 10, + "percentage": 40 }, "lines": { - "covered": 108, - "total": 124, - "percentage": 87.09 + "covered": 116, + "total": 151, + "percentage": 76.82 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1424,8 +1468,8 @@ }, "src/vs/platform/agentHost/common/serverToolNames.ts": { "statements": { - "covered": 27, - "total": 27, + "covered": 28, + "total": 28, "percentage": 100 }, "branches": { @@ -1439,8 +1483,8 @@ "percentage": 100 }, "lines": { - "covered": 27, - "total": 27, + "covered": 28, + "total": 28, "percentage": 100 } }, @@ -1468,23 +1512,23 @@ }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 466, - "total": 466, + "covered": 478, + "total": 478, "percentage": 100 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "lines": { - "covered": 466, - "total": 466, + "covered": 478, + "total": 478, "percentage": 100 } }, @@ -1622,14 +1666,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { "statements": { - "covered": 92, + "covered": 93, "total": 121, - "percentage": 76.03 + "percentage": 76.85 }, "branches": { - "covered": 16, - "total": 26, - "percentage": 61.53 + "covered": 20, + "total": 29, + "percentage": 68.96 }, "functions": { "covered": 1, @@ -1637,9 +1681,9 @@ "percentage": 100 }, "lines": { - "covered": 92, + "covered": 93, "total": 121, - "percentage": 76.03 + "percentage": 76.85 } }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { @@ -1710,24 +1754,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts": { "statements": { - "covered": 284, - "total": 401, - "percentage": 70.82 + "covered": 299, + "total": 420, + "percentage": 71.19 }, "branches": { - "covered": 60, - "total": 85, - "percentage": 70.58 + "covered": 62, + "total": 89, + "percentage": 69.66 }, "functions": { - "covered": 5, - "total": 5, + "covered": 6, + "total": 6, "percentage": 100 }, "lines": { - "covered": 284, - "total": 401, - "percentage": 70.82 + "covered": 299, + "total": 420, + "percentage": 71.19 } }, "src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts": { @@ -1842,8 +1886,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/notifications.ts": { "statements": { - "covered": 63, - "total": 63, + "covered": 67, + "total": 67, "percentage": 100 }, "branches": { @@ -1857,8 +1901,8 @@ "percentage": 100 }, "lines": { - "covered": 63, - "total": 63, + "covered": 67, + "total": 67, "percentage": 100 } }, @@ -2128,24 +2172,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1370, - "total": 1829, - "percentage": 74.9 + "covered": 1401, + "total": 1848, + "percentage": 75.81 }, "branches": { - "covered": 147, - "total": 215, - "percentage": 68.37 + "covered": 160, + "total": 232, + "percentage": 68.96 }, "functions": { - "covered": 55, - "total": 83, - "percentage": 66.26 + "covered": 58, + "total": 85, + "percentage": 68.23 }, "lines": { - "covered": 1370, - "total": 1829, - "percentage": 74.9 + "covered": 1401, + "total": 1848, + "percentage": 75.81 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -2282,24 +2326,24 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 389, - "total": 422, - "percentage": 92.18 + "covered": 385, + "total": 420, + "percentage": 91.66 }, "branches": { - "covered": 42, - "total": 57, - "percentage": 73.68 + "covered": 44, + "total": 60, + "percentage": 73.33 }, "functions": { - "covered": 16, - "total": 17, - "percentage": 94.11 + "covered": 17, + "total": 18, + "percentage": 94.44 }, "lines": { - "covered": 389, - "total": 422, - "percentage": 92.18 + "covered": 385, + "total": 420, + "percentage": 91.66 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { @@ -2392,14 +2436,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 381, - "total": 441, - "percentage": 86.39 + "covered": 380, + "total": 440, + "percentage": 86.36 }, "branches": { - "covered": 67, - "total": 89, - "percentage": 75.28 + "covered": 63, + "total": 85, + "percentage": 74.11 }, "functions": { "covered": 29, @@ -2407,43 +2451,43 @@ "percentage": 96.66 }, "lines": { - "covered": 381, - "total": 441, - "percentage": 86.39 + "covered": 380, + "total": 440, + "percentage": 86.36 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 252, + "covered": 265, "total": 296, - "percentage": 85.13 + "percentage": 89.52 }, "branches": { - "covered": 51, - "total": 66, - "percentage": 77.27 + "covered": 53, + "total": 69, + "percentage": 76.81 }, "functions": { - "covered": 12, + "covered": 13, "total": 13, - "percentage": 92.3 + "percentage": 100 }, "lines": { - "covered": 252, + "covered": 265, "total": 296, - "percentage": 85.13 + "percentage": 89.52 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1226, - "total": 1645, - "percentage": 74.52 + "covered": 1229, + "total": 1646, + "percentage": 74.66 }, "branches": { - "covered": 184, + "covered": 185, "total": 258, - "percentage": 71.31 + "percentage": 71.7 }, "functions": { "covered": 56, @@ -2451,9 +2495,9 @@ "percentage": 80 }, "lines": { - "covered": 1226, - "total": 1645, - "percentage": 74.52 + "covered": 1229, + "total": 1646, + "percentage": 74.66 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2502,9 +2546,9 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts": { "statements": { - "covered": 148, - "total": 162, - "percentage": 91.35 + "covered": 151, + "total": 165, + "percentage": 91.51 }, "branches": { "covered": 10, @@ -2517,31 +2561,31 @@ "percentage": 66.66 }, "lines": { - "covered": 148, - "total": 162, - "percentage": 91.35 + "covered": 151, + "total": 165, + "percentage": 91.51 } }, "src/vs/platform/agentHost/node/agentHostChatCompletionProvider.ts": { "statements": { - "covered": 133, + "covered": 204, "total": 216, - "percentage": 61.57 + "percentage": 94.44 }, "branches": { - "covered": 11, - "total": 22, - "percentage": 50 + "covered": 41, + "total": 52, + "percentage": 78.84 }, "functions": { - "covered": 3, + "covered": 6, "total": 6, - "percentage": 50 + "percentage": 100 }, "lines": { - "covered": 133, + "covered": 204, "total": 216, - "percentage": 61.57 + "percentage": 94.44 } }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { @@ -2551,9 +2595,9 @@ "percentage": 70.86 }, "branches": { - "covered": 80, - "total": 110, - "percentage": 72.72 + "covered": 77, + "total": 107, + "percentage": 71.96 }, "functions": { "covered": 17, @@ -2654,26 +2698,48 @@ "percentage": 96.99 } }, + "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { + "statements": { + "covered": 558, + "total": 723, + "percentage": 77.17 + }, + "branches": { + "covered": 100, + "total": 143, + "percentage": 69.93 + }, + "functions": { + "covered": 41, + "total": 47, + "percentage": 87.23 + }, + "lines": { + "covered": 558, + "total": 723, + "percentage": 77.17 + } + }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 267, - "total": 304, - "percentage": 87.82 + "covered": 301, + "total": 360, + "percentage": 83.61 }, "branches": { - "covered": 42, - "total": 56, + "covered": 45, + "total": 60, "percentage": 75 }, "functions": { - "covered": 23, + "covered": 22, "total": 28, - "percentage": 82.14 + "percentage": 78.57 }, "lines": { - "covered": 267, - "total": 304, - "percentage": 87.82 + "covered": 301, + "total": 360, + "percentage": 83.61 } }, "src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts": { @@ -2722,14 +2788,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts": { "statements": { - "covered": 245, + "covered": 247, "total": 317, - "percentage": 77.28 + "percentage": 77.91 }, "branches": { - "covered": 35, + "covered": 36, "total": 57, - "percentage": 61.4 + "percentage": 63.15 }, "functions": { "covered": 8, @@ -2737,9 +2803,9 @@ "percentage": 88.88 }, "lines": { - "covered": 245, + "covered": 247, "total": 317, - "percentage": 77.28 + "percentage": 77.91 } }, "src/vs/platform/agentHost/node/agentHostFileCompletionUtils.ts": { @@ -2771,9 +2837,9 @@ "percentage": 89.18 }, "branches": { - "covered": 22, - "total": 34, - "percentage": 64.7 + "covered": 23, + "total": 35, + "percentage": 65.71 }, "functions": { "covered": 14, @@ -2832,36 +2898,36 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 1117, - "total": 1692, - "percentage": 66.01 + "covered": 1194, + "total": 1697, + "percentage": 70.35 }, "branches": { - "covered": 226, - "total": 323, - "percentage": 69.96 + "covered": 263, + "total": 371, + "percentage": 70.88 }, "functions": { - "covered": 55, - "total": 79, - "percentage": 69.62 + "covered": 61, + "total": 80, + "percentage": 76.25 }, "lines": { - "covered": 1117, - "total": 1692, - "percentage": 66.01 + "covered": 1194, + "total": 1697, + "percentage": 70.35 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 }, "branches": { - "covered": 68, - "total": 92, - "percentage": 73.91 + "covered": 63, + "total": 88, + "percentage": 71.59 }, "functions": { "covered": 9, @@ -2869,9 +2935,9 @@ "percentage": 64.28 }, "lines": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -2898,9 +2964,9 @@ }, "src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts": { "statements": { - "covered": 142, - "total": 160, - "percentage": 88.75 + "covered": 145, + "total": 163, + "percentage": 88.95 }, "branches": { "covered": 47, @@ -2909,13 +2975,13 @@ }, "functions": { "covered": 13, - "total": 13, - "percentage": 100 + "total": 14, + "percentage": 92.85 }, "lines": { - "covered": 142, - "total": 160, - "percentage": 88.75 + "covered": 145, + "total": 163, + "percentage": 88.95 } }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { @@ -2925,9 +2991,9 @@ "percentage": 80.74 }, "branches": { - "covered": 14, - "total": 23, - "percentage": 60.86 + "covered": 15, + "total": 24, + "percentage": 62.5 }, "functions": { "covered": 9, @@ -2991,9 +3057,9 @@ "percentage": 81 }, "branches": { - "covered": 25, - "total": 33, - "percentage": 75.75 + "covered": 24, + "total": 32, + "percentage": 75 }, "functions": { "covered": 5, @@ -3206,9 +3272,9 @@ }, "src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts": { "statements": { - "covered": 170, - "total": 308, - "percentage": 55.19 + "covered": 174, + "total": 314, + "percentage": 55.41 }, "branches": { "covered": 2, @@ -3217,13 +3283,13 @@ }, "functions": { "covered": 1, - "total": 14, - "percentage": 7.14 + "total": 15, + "percentage": 6.66 }, "lines": { - "covered": 170, - "total": 308, - "percentage": 55.19 + "covered": 174, + "total": 314, + "percentage": 55.41 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { @@ -3250,9 +3316,9 @@ }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 453, - "total": 503, - "percentage": 90.05 + "covered": 458, + "total": 508, + "percentage": 90.15 }, "branches": { "covered": 23, @@ -3265,9 +3331,9 @@ "percentage": 85.71 }, "lines": { - "covered": 453, - "total": 503, - "percentage": 90.05 + "covered": 458, + "total": 508, + "percentage": 90.15 } }, "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { @@ -3294,24 +3360,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 549, - "total": 694, - "percentage": 79.1 + "covered": 618, + "total": 831, + "percentage": 74.36 }, "branches": { - "covered": 100, - "total": 129, - "percentage": 77.51 + "covered": 118, + "total": 159, + "percentage": 74.21 }, "functions": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 29, + "total": 42, + "percentage": 69.04 }, "lines": { - "covered": 549, - "total": 694, - "percentage": 79.1 + "covered": 618, + "total": 831, + "percentage": 74.36 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -3360,14 +3426,14 @@ }, "src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts": { "statements": { - "covered": 67, - "total": 129, - "percentage": 51.93 + "covered": 68, + "total": 130, + "percentage": 52.3 }, "branches": { - "covered": 5, - "total": 8, - "percentage": 62.5 + "covered": 6, + "total": 9, + "percentage": 66.66 }, "functions": { "covered": 2, @@ -3375,9 +3441,9 @@ "percentage": 33.33 }, "lines": { - "covered": 67, - "total": 129, - "percentage": 51.93 + "covered": 68, + "total": 130, + "percentage": 52.3 } }, "src/vs/platform/agentHost/node/agentHostSlashCompletion.ts": { @@ -3387,9 +3453,9 @@ "percentage": 89.53 }, "branches": { - "covered": 21, - "total": 26, - "percentage": 80.76 + "covered": 22, + "total": 27, + "percentage": 81.48 }, "functions": { "covered": 4, @@ -3405,96 +3471,118 @@ "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { "covered": 1638, - "total": 1762, - "percentage": 92.96 + "total": 1776, + "percentage": 92.22 }, "branches": { - "covered": 264, - "total": 313, - "percentage": 84.34 + "covered": 263, + "total": 310, + "percentage": 84.83 }, "functions": { - "covered": 72, - "total": 77, - "percentage": 93.5 + "covered": 70, + "total": 78, + "percentage": 89.74 }, "lines": { "covered": 1638, - "total": 1762, - "percentage": 92.96 + "total": 1776, + "percentage": 92.22 + } + }, + "src/vs/platform/agentHost/node/agentHostStorageService.ts": { + "statements": { + "covered": 110, + "total": 121, + "percentage": 90.9 + }, + "branches": { + "covered": 17, + "total": 23, + "percentage": 73.91 + }, + "functions": { + "covered": 10, + "total": 10, + "percentage": 100 + }, + "lines": { + "covered": 110, + "total": 121, + "percentage": 90.9 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { "statements": { - "covered": 31, + "covered": 67, "total": 80, - "percentage": 38.75 + "percentage": 83.75 }, "branches": { - "covered": 1, - "total": 1, - "percentage": 100 + "covered": 7, + "total": 15, + "percentage": 46.66 }, "functions": { - "covered": 1, + "covered": 3, "total": 3, - "percentage": 33.33 + "percentage": 100 }, "lines": { - "covered": 31, + "covered": 67, "total": 80, - "percentage": 38.75 + "percentage": 83.75 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts": { "statements": { - "covered": 49, + "covered": 61, "total": 61, - "percentage": 80.32 + "percentage": 100 }, "branches": { - "covered": 8, - "total": 11, - "percentage": 72.72 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "functions": { - "covered": 4, + "covered": 6, "total": 6, - "percentage": 66.66 + "percentage": 100 }, "lines": { - "covered": 49, + "covered": 61, "total": 61, - "percentage": 80.32 + "percentage": 100 } }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1068, - "total": 1218, - "percentage": 87.68 + "covered": 1126, + "total": 1280, + "percentage": 87.96 }, "branches": { - "covered": 51, - "total": 73, - "percentage": 69.86 + "covered": 66, + "total": 96, + "percentage": 68.75 }, "functions": { - "covered": 15, - "total": 22, - "percentage": 68.18 + "covered": 16, + "total": 23, + "percentage": 69.56 }, "lines": { - "covered": 1068, - "total": 1218, - "percentage": 87.68 + "covered": 1126, + "total": 1280, + "percentage": 87.96 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { "covered": 188, - "total": 277, - "percentage": 67.87 + "total": 278, + "percentage": 67.62 }, "branches": { "covered": 18, @@ -3508,8 +3596,8 @@ }, "lines": { "covered": 188, - "total": 277, - "percentage": 67.87 + "total": 278, + "percentage": 67.62 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -3519,9 +3607,9 @@ "percentage": 90.83 }, "branches": { - "covered": 120, - "total": 149, - "percentage": 80.53 + "covered": 119, + "total": 148, + "percentage": 80.4 }, "functions": { "covered": 35, @@ -3536,9 +3624,9 @@ }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 259, - "total": 299, - "percentage": 86.62 + "covered": 266, + "total": 309, + "percentage": 86.08 }, "branches": { "covered": 54, @@ -3547,35 +3635,35 @@ }, "functions": { "covered": 15, - "total": 15, - "percentage": 100 + "total": 16, + "percentage": 93.75 }, "lines": { - "covered": 259, - "total": 299, - "percentage": 86.62 + "covered": 266, + "total": 309, + "percentage": 86.08 } }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { "statements": { - "covered": 385, - "total": 484, - "percentage": 79.54 + "covered": 392, + "total": 495, + "percentage": 79.19 }, "branches": { - "covered": 42, - "total": 52, - "percentage": 80.76 + "covered": 34, + "total": 46, + "percentage": 73.91 }, "functions": { - "covered": 18, - "total": 22, - "percentage": 81.81 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "lines": { - "covered": 385, - "total": 484, - "percentage": 79.54 + "covered": 392, + "total": 495, + "percentage": 79.19 } }, "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { @@ -3607,9 +3695,9 @@ "percentage": 71.12 }, "branches": { - "covered": 14, + "covered": 15, "total": 30, - "percentage": 46.66 + "percentage": 50 }, "functions": { "covered": 5, @@ -3646,46 +3734,46 @@ }, "src/vs/platform/agentHost/node/agentPeerChats.ts": { "statements": { - "covered": 186, - "total": 359, - "percentage": 51.81 + "covered": 204, + "total": 390, + "percentage": 52.3 }, "branches": { - "covered": 14, - "total": 46, - "percentage": 30.43 + "covered": 17, + "total": 50, + "percentage": 34 }, "functions": { - "covered": 6, - "total": 24, - "percentage": 25 + "covered": 7, + "total": 27, + "percentage": 25.92 }, "lines": { - "covered": 186, - "total": 359, - "percentage": 51.81 + "covered": 204, + "total": 390, + "percentage": 52.3 } }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { "statements": { - "covered": 250, - "total": 292, - "percentage": 85.61 + "covered": 249, + "total": 294, + "percentage": 84.69 }, "branches": { - "covered": 32, - "total": 45, - "percentage": 71.11 + "covered": 30, + "total": 43, + "percentage": 69.76 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 17, + "total": 20, + "percentage": 85 }, "lines": { - "covered": 250, - "total": 292, - "percentage": 85.61 + "covered": 249, + "total": 294, + "percentage": 84.69 } }, "src/vs/platform/agentHost/node/agentSdkDownloader.ts": { @@ -3712,68 +3800,68 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 4285, - "total": 5457, - "percentage": 78.52 + "covered": 4438, + "total": 5740, + "percentage": 77.31 }, "branches": { - "covered": 729, - "total": 1087, - "percentage": 67.06 + "covered": 772, + "total": 1153, + "percentage": 66.95 }, "functions": { - "covered": 174, - "total": 206, - "percentage": 84.46 + "covered": 188, + "total": 228, + "percentage": 82.45 }, "lines": { - "covered": 4285, - "total": 5457, - "percentage": 78.52 + "covered": 4438, + "total": 5740, + "percentage": 77.31 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 132, - "total": 138, - "percentage": 95.65 + "covered": 139, + "total": 156, + "percentage": 89.1 }, "branches": { - "covered": 10, - "total": 10, - "percentage": 100 + "covered": 12, + "total": 16, + "percentage": 75 }, "functions": { - "covered": 9, - "total": 12, - "percentage": 75 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "lines": { - "covered": 132, - "total": 138, - "percentage": 95.65 + "covered": 139, + "total": 156, + "percentage": 89.1 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1898, - "total": 2164, - "percentage": 87.7 + "covered": 2011, + "total": 2325, + "percentage": 86.49 }, "branches": { - "covered": 369, - "total": 468, - "percentage": 78.84 + "covered": 399, + "total": 513, + "percentage": 77.77 }, "functions": { - "covered": 62, - "total": 64, - "percentage": 96.87 + "covered": 66, + "total": 72, + "percentage": 91.66 }, "lines": { - "covered": 1898, - "total": 2164, - "percentage": 87.7 + "covered": 2011, + "total": 2325, + "percentage": 86.49 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -3866,68 +3954,68 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2076, - "total": 2558, - "percentage": 81.15 + "covered": 2126, + "total": 2624, + "percentage": 81.02 }, "branches": { - "covered": 200, - "total": 302, - "percentage": 66.22 + "covered": 211, + "total": 319, + "percentage": 66.14 }, "functions": { - "covered": 89, - "total": 113, - "percentage": 78.76 + "covered": 95, + "total": 119, + "percentage": 79.83 }, "lines": { - "covered": 2076, - "total": 2558, - "percentage": 81.15 + "covered": 2126, + "total": 2624, + "percentage": 81.02 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 267, - "total": 312, - "percentage": 85.57 + "covered": 276, + "total": 323, + "percentage": 85.44 }, "branches": { - "covered": 13, - "total": 18, - "percentage": 72.22 + "covered": 14, + "total": 20, + "percentage": 70 }, "functions": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 12, + "total": 16, + "percentage": 75 }, "lines": { - "covered": 267, - "total": 312, - "percentage": 85.57 + "covered": 276, + "total": 323, + "percentage": 85.44 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1094, - "total": 1348, - "percentage": 81.15 + "covered": 1168, + "total": 1463, + "percentage": 79.83 }, "branches": { - "covered": 62, - "total": 95, - "percentage": 65.26 + "covered": 69, + "total": 111, + "percentage": 62.16 }, "functions": { - "covered": 33, - "total": 56, - "percentage": 58.92 + "covered": 37, + "total": 62, + "percentage": 59.67 }, "lines": { - "covered": 1094, - "total": 1348, - "percentage": 81.15 + "covered": 1168, + "total": 1463, + "percentage": 79.83 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -3998,9 +4086,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts": { "statements": { - "covered": 140, - "total": 149, - "percentage": 93.95 + "covered": 141, + "total": 150, + "percentage": 94 }, "branches": { "covered": 13, @@ -4013,9 +4101,9 @@ "percentage": 100 }, "lines": { - "covered": 140, - "total": 149, - "percentage": 93.95 + "covered": 141, + "total": 150, + "percentage": 94 } }, "src/vs/platform/agentHost/node/claude/claudeInteractiveTools.ts": { @@ -4130,9 +4218,9 @@ }, "src/vs/platform/agentHost/node/claude/claudePromptQueue.ts": { "statements": { - "covered": 155, - "total": 171, - "percentage": 90.64 + "covered": 157, + "total": 173, + "percentage": 90.75 }, "branches": { "covered": 16, @@ -4145,9 +4233,9 @@ "percentage": 90.9 }, "lines": { - "covered": 155, - "total": 171, - "percentage": 90.64 + "covered": 157, + "total": 173, + "percentage": 90.75 } }, "src/vs/platform/agentHost/node/claude/claudePromptResolver.ts": { @@ -4240,9 +4328,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts": { "statements": { - "covered": 89, - "total": 93, - "percentage": 95.69 + "covered": 91, + "total": 95, + "percentage": 95.78 }, "branches": { "covered": 10, @@ -4255,43 +4343,43 @@ "percentage": 66.66 }, "lines": { - "covered": 89, - "total": 93, - "percentage": 95.69 + "covered": 91, + "total": 95, + "percentage": 95.78 } }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { "statements": { - "covered": 274, - "total": 334, - "percentage": 82.03 + "covered": 287, + "total": 347, + "percentage": 82.7 }, "branches": { - "covered": 13, - "total": 39, - "percentage": 33.33 + "covered": 14, + "total": 41, + "percentage": 34.14 }, "functions": { - "covered": 4, - "total": 7, - "percentage": 57.14 + "covered": 5, + "total": 8, + "percentage": 62.5 }, "lines": { - "covered": 274, - "total": 334, - "percentage": 82.03 + "covered": 287, + "total": 347, + "percentage": 82.7 } }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { "statements": { - "covered": 552, - "total": 733, - "percentage": 75.3 + "covered": 557, + "total": 739, + "percentage": 75.37 }, "branches": { - "covered": 40, - "total": 63, - "percentage": 63.49 + "covered": 39, + "total": 62, + "percentage": 62.9 }, "functions": { "covered": 20, @@ -4299,9 +4387,9 @@ "percentage": 64.51 }, "lines": { - "covered": 552, - "total": 733, - "percentage": 75.3 + "covered": 557, + "total": 739, + "percentage": 75.37 } }, "src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts": { @@ -4328,24 +4416,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 201, - "total": 237, - "percentage": 84.81 + "covered": 225, + "total": 261, + "percentage": 86.2 }, "branches": { - "covered": 17, - "total": 35, - "percentage": 48.57 + "covered": 26, + "total": 41, + "percentage": 63.41 }, "functions": { - "covered": 8, - "total": 8, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 201, - "total": 237, - "percentage": 84.81 + "covered": 225, + "total": 261, + "percentage": 86.2 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -4680,9 +4768,9 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { "statements": { - "covered": 168, - "total": 233, - "percentage": 72.1 + "covered": 169, + "total": 237, + "percentage": 71.3 }, "branches": { "covered": 10, @@ -4695,21 +4783,21 @@ "percentage": 64.28 }, "lines": { - "covered": 168, - "total": 233, - "percentage": 72.1 + "covered": 169, + "total": 237, + "percentage": 71.3 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { "statements": { "covered": 424, - "total": 551, - "percentage": 76.95 + "total": 550, + "percentage": 77.09 }, "branches": { - "covered": 44, - "total": 69, - "percentage": 63.76 + "covered": 43, + "total": 68, + "percentage": 63.23 }, "functions": { "covered": 12, @@ -4718,8 +4806,8 @@ }, "lines": { "covered": 424, - "total": 551, - "percentage": 76.95 + "total": 550, + "percentage": 77.09 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts": { @@ -4856,90 +4944,112 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 3879, - "total": 5890, - "percentage": 65.85 + "covered": 4112, + "total": 6217, + "percentage": 66.14 }, "branches": { - "covered": 364, - "total": 690, - "percentage": 52.75 + "covered": 411, + "total": 767, + "percentage": 53.58 }, "functions": { - "covered": 153, - "total": 210, - "percentage": 72.85 + "covered": 168, + "total": 227, + "percentage": 74 }, "lines": { - "covered": 3879, - "total": 5890, - "percentage": 65.85 + "covered": 4112, + "total": 6217, + "percentage": 66.14 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { "statements": { - "covered": 406, + "covered": 414, "total": 481, - "percentage": 84.4 + "percentage": 86.07 }, "branches": { - "covered": 33, - "total": 57, - "percentage": 57.89 + "covered": 38, + "total": 59, + "percentage": 64.4 }, "functions": { - "covered": 16, + "covered": 17, "total": 19, - "percentage": 84.21 + "percentage": 89.47 }, "lines": { - "covered": 406, + "covered": 414, "total": 481, - "percentage": 84.4 + "percentage": 86.07 } }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 260, - "total": 316, - "percentage": 82.27 + "covered": 300, + "total": 360, + "percentage": 83.33 }, "branches": { - "covered": 27, - "total": 51, - "percentage": 52.94 + "covered": 32, + "total": 62, + "percentage": 51.61 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "lines": { - "covered": 260, - "total": 316, - "percentage": 82.27 + "covered": 300, + "total": 360, + "percentage": 83.33 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { "statements": { - "covered": 175, - "total": 213, - "percentage": 82.15 + "covered": 213, + "total": 286, + "percentage": 74.47 }, "branches": { - "covered": 17, - "total": 30, - "percentage": 56.66 + "covered": 19, + "total": 36, + "percentage": 52.77 }, "functions": { - "covered": 7, - "total": 8, - "percentage": 87.5 + "covered": 8, + "total": 9, + "percentage": 88.88 + }, + "lines": { + "covered": 213, + "total": 286, + "percentage": 74.47 + } + }, + "src/vs/platform/agentHost/node/codex/codexDelegation.ts": { + "statements": { + "covered": 29, + "total": 46, + "percentage": 63.04 + }, + "branches": { + "covered": 5, + "total": 9, + "percentage": 55.55 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 }, "lines": { - "covered": 175, - "total": 213, - "percentage": 82.15 + "covered": 29, + "total": 46, + "percentage": 63.04 } }, "src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts": { @@ -5054,24 +5164,24 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 634, - "total": 1250, - "percentage": 50.72 + "covered": 629, + "total": 1258, + "percentage": 50 }, "branches": { - "covered": 63, - "total": 118, - "percentage": 53.38 + "covered": 62, + "total": 119, + "percentage": 52.1 }, "functions": { - "covered": 19, + "covered": 18, "total": 42, - "percentage": 45.23 + "percentage": 42.85 }, "lines": { - "covered": 634, - "total": 1250, - "percentage": 50.72 + "covered": 629, + "total": 1258, + "percentage": 50 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -5164,24 +5274,46 @@ }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 149, - "total": 293, - "percentage": 50.85 + "covered": 178, + "total": 378, + "percentage": 47.08 }, "branches": { "covered": 6, - "total": 19, - "percentage": 31.57 + "total": 26, + "percentage": 23.07 }, "functions": { "covered": 4, - "total": 10, - "percentage": 40 + "total": 13, + "percentage": 30.76 }, "lines": { - "covered": 149, - "total": 293, - "percentage": 50.85 + "covered": 178, + "total": 378, + "percentage": 47.08 + } + }, + "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { + "statements": { + "covered": 117, + "total": 159, + "percentage": 73.58 + }, + "branches": { + "covered": 25, + "total": 34, + "percentage": 73.52 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 117, + "total": 159, + "percentage": 73.58 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -5208,24 +5340,24 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 207, - "total": 232, - "percentage": 89.22 + "covered": 229, + "total": 256, + "percentage": 89.45 }, "branches": { - "covered": 16, - "total": 36, - "percentage": 44.44 + "covered": 18, + "total": 39, + "percentage": 46.15 }, "functions": { - "covered": 6, - "total": 6, + "covered": 7, + "total": 7, "percentage": 100 }, "lines": { - "covered": 207, - "total": 232, - "percentage": 89.22 + "covered": 229, + "total": 256, + "percentage": 89.45 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { @@ -5250,6 +5382,28 @@ "percentage": 85.71 } }, + "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { + "statements": { + "covered": 67, + "total": 189, + "percentage": 35.44 + }, + "branches": { + "covered": 1, + "total": 9, + "percentage": 11.11 + }, + "functions": { + "covered": 1, + "total": 8, + "percentage": 12.5 + }, + "lines": { + "covered": 67, + "total": 189, + "percentage": 35.44 + } + }, "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { "statements": { "covered": 62, @@ -5318,24 +5472,24 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 554, - "total": 695, - "percentage": 79.71 + "covered": 567, + "total": 704, + "percentage": 80.53 }, "branches": { - "covered": 37, + "covered": 39, "total": 82, - "percentage": 45.12 + "percentage": 47.56 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 18, + "total": 21, + "percentage": 85.71 }, "lines": { - "covered": 554, - "total": 695, - "percentage": 79.71 + "covered": 567, + "total": 704, + "percentage": 80.53 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { @@ -5428,46 +5582,46 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4060, - "total": 5376, - "percentage": 75.52 + "covered": 4365, + "total": 5802, + "percentage": 75.23 }, "branches": { - "covered": 648, - "total": 1003, - "percentage": 64.6 + "covered": 716, + "total": 1102, + "percentage": 64.97 }, "functions": { - "covered": 227, - "total": 277, - "percentage": 81.94 + "covered": 245, + "total": 300, + "percentage": 81.66 }, "lines": { - "covered": 4060, - "total": 5376, - "percentage": 75.52 + "covered": 4365, + "total": 5802, + "percentage": 75.23 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4116, - "total": 5500, - "percentage": 74.83 + "covered": 4128, + "total": 5537, + "percentage": 74.55 }, "branches": { - "covered": 736, - "total": 1064, - "percentage": 69.17 + "covered": 740, + "total": 1074, + "percentage": 68.9 }, "functions": { - "covered": 167, - "total": 206, - "percentage": 81.06 + "covered": 174, + "total": 215, + "percentage": 80.93 }, "lines": { - "covered": 4116, - "total": 5500, - "percentage": 74.83 + "covered": 4128, + "total": 5537, + "percentage": 74.55 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -5516,9 +5670,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts": { "statements": { - "covered": 292, - "total": 402, - "percentage": 72.63 + "covered": 295, + "total": 405, + "percentage": 72.83 }, "branches": { "covered": 6, @@ -5531,16 +5685,16 @@ "percentage": 45.45 }, "lines": { - "covered": 292, - "total": 402, - "percentage": 72.63 + "covered": 295, + "total": 405, + "percentage": 72.83 } }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 218, - "total": 224, - "percentage": 97.32 + "covered": 250, + "total": 256, + "percentage": 97.65 }, "branches": { "covered": 5, @@ -5553,9 +5707,9 @@ "percentage": 100 }, "lines": { - "covered": 218, - "total": 224, - "percentage": 97.32 + "covered": 250, + "total": 256, + "percentage": 97.65 } }, "src/vs/platform/agentHost/node/copilot/copilotGitProject.ts": { @@ -5604,24 +5758,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 362, - "total": 516, - "percentage": 70.15 + "covered": 369, + "total": 518, + "percentage": 71.23 }, "branches": { - "covered": 41, - "total": 74, - "percentage": 55.4 + "covered": 42, + "total": 75, + "percentage": 56 }, "functions": { - "covered": 18, + "covered": 19, "total": 25, - "percentage": 72 + "percentage": 76 }, "lines": { - "covered": 362, - "total": 516, - "percentage": 70.15 + "covered": 369, + "total": 518, + "percentage": 71.23 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -5648,24 +5802,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 565, - "total": 688, - "percentage": 82.12 + "covered": 677, + "total": 860, + "percentage": 78.72 }, "branches": { - "covered": 63, - "total": 95, - "percentage": 66.31 + "covered": 80, + "total": 120, + "percentage": 66.66 }, "functions": { - "covered": 25, - "total": 31, - "percentage": 80.64 + "covered": 34, + "total": 45, + "percentage": 75.55 }, "lines": { - "covered": 565, - "total": 688, - "percentage": 82.12 + "covered": 677, + "total": 860, + "percentage": 78.72 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { @@ -5714,9 +5868,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { "statements": { - "covered": 99, - "total": 269, - "percentage": 36.8 + "covered": 100, + "total": 270, + "percentage": 37.03 }, "branches": { "covered": 2, @@ -5729,9 +5883,9 @@ "percentage": 25 }, "lines": { - "covered": 99, - "total": 269, - "percentage": 36.8 + "covered": 100, + "total": 270, + "percentage": 37.03 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts": { @@ -5780,9 +5934,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts": { "statements": { - "covered": 167, - "total": 238, - "percentage": 70.16 + "covered": 170, + "total": 241, + "percentage": 70.53 }, "branches": { "covered": 40, @@ -5795,9 +5949,9 @@ "percentage": 77.77 }, "lines": { - "covered": 167, - "total": 238, - "percentage": 70.16 + "covered": 170, + "total": 241, + "percentage": 70.53 } }, "src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts": { @@ -5830,8 +5984,8 @@ }, "branches": { "covered": 164, - "total": 276, - "percentage": 59.42 + "total": 277, + "percentage": 59.2 }, "functions": { "covered": 27, @@ -5846,9 +6000,9 @@ }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 662, - "total": 944, - "percentage": 70.12 + "covered": 666, + "total": 948, + "percentage": 70.25 }, "branches": { "covered": 87, @@ -5861,9 +6015,9 @@ "percentage": 95.45 }, "lines": { - "covered": 662, - "total": 944, - "percentage": 70.12 + "covered": 666, + "total": 948, + "percentage": 70.25 } }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { @@ -5934,9 +6088,9 @@ }, "src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts": { "statements": { - "covered": 49, - "total": 72, - "percentage": 68.05 + "covered": 47, + "total": 66, + "percentage": 71.21 }, "branches": { "covered": 3, @@ -5949,21 +6103,21 @@ "percentage": 60 }, "lines": { - "covered": 49, - "total": 72, - "percentage": 68.05 + "covered": 47, + "total": 66, + "percentage": 71.21 } }, "src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts": { "statements": { - "covered": 221, - "total": 241, - "percentage": 91.7 + "covered": 206, + "total": 229, + "percentage": 89.95 }, "branches": { - "covered": 16, - "total": 26, - "percentage": 61.53 + "covered": 13, + "total": 23, + "percentage": 56.52 }, "functions": { "covered": 7, @@ -5971,31 +6125,31 @@ "percentage": 100 }, "lines": { - "covered": 221, - "total": 241, - "percentage": 91.7 + "covered": 206, + "total": 229, + "percentage": 89.95 } }, "src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts": { "statements": { - "covered": 109, - "total": 117, - "percentage": 93.16 + "covered": 115, + "total": 126, + "percentage": 91.26 }, "branches": { "covered": 5, - "total": 12, - "percentage": 41.66 + "total": 11, + "percentage": 45.45 }, "functions": { "covered": 2, - "total": 4, - "percentage": 50 + "total": 5, + "percentage": 40 }, "lines": { - "covered": 109, - "total": 117, - "percentage": 93.16 + "covered": 115, + "total": 126, + "percentage": 91.26 } }, "src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts": { @@ -6176,24 +6330,24 @@ }, "src/vs/platform/agentHost/node/localCommands/localChatCommand.ts": { "statements": { - "covered": 240, - "total": 251, - "percentage": 95.61 + "covered": 244, + "total": 255, + "percentage": 95.68 }, "branches": { - "covered": 28, - "total": 36, - "percentage": 77.77 + "covered": 29, + "total": 37, + "percentage": 78.37 }, "functions": { - "covered": 12, - "total": 12, + "covered": 13, + "total": 13, "percentage": 100 }, "lines": { - "covered": 240, - "total": 251, - "percentage": 95.61 + "covered": 244, + "total": 255, + "percentage": 95.68 } }, "src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts": { @@ -6220,14 +6374,14 @@ }, "src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts": { "statements": { - "covered": 70, - "total": 70, + "covered": 75, + "total": 75, "percentage": 100 }, "branches": { - "covered": 18, - "total": 19, - "percentage": 94.73 + "covered": 14, + "total": 15, + "percentage": 93.33 }, "functions": { "covered": 5, @@ -6235,8 +6389,8 @@ "percentage": 100 }, "lines": { - "covered": 70, - "total": 70, + "covered": 75, + "total": 75, "percentage": 100 } }, @@ -6308,24 +6462,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1590, - "total": 1813, - "percentage": 87.69 + "covered": 1603, + "total": 1830, + "percentage": 87.59 }, "branches": { - "covered": 300, - "total": 375, - "percentage": 80 + "covered": 303, + "total": 380, + "percentage": 79.73 }, "functions": { - "covered": 75, - "total": 85, - "percentage": 88.23 + "covered": 77, + "total": 88, + "percentage": 87.5 }, "lines": { - "covered": 1590, - "total": 1813, - "percentage": 87.69 + "covered": 1603, + "total": 1830, + "percentage": 87.59 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -6352,14 +6506,14 @@ }, "src/vs/platform/agentHost/node/sessionDataService.ts": { "statements": { - "covered": 155, + "covered": 157, "total": 198, - "percentage": 78.28 + "percentage": 79.29 }, "branches": { - "covered": 20, - "total": 24, - "percentage": 83.33 + "covered": 22, + "total": 25, + "percentage": 88 }, "functions": { "covered": 12, @@ -6367,16 +6521,16 @@ "percentage": 85.71 }, "lines": { - "covered": 155, + "covered": 157, "total": 198, - "percentage": 78.28 + "percentage": 79.29 } }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 742, - "total": 872, - "percentage": 85.09 + "covered": 736, + "total": 882, + "percentage": 83.44 }, "branches": { "covered": 107, @@ -6385,13 +6539,13 @@ }, "functions": { "covered": 39, - "total": 53, - "percentage": 73.58 + "total": 54, + "percentage": 72.22 }, "lines": { - "covered": 742, - "total": 872, - "percentage": 85.09 + "covered": 736, + "total": 882, + "percentage": 83.44 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -6418,14 +6572,14 @@ }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 536, - "total": 694, - "percentage": 77.23 + "covered": 529, + "total": 687, + "percentage": 77 }, "branches": { - "covered": 86, - "total": 127, - "percentage": 67.71 + "covered": 89, + "total": 131, + "percentage": 67.93 }, "functions": { "covered": 23, @@ -6433,9 +6587,9 @@ "percentage": 82.14 }, "lines": { - "covered": 536, - "total": 694, - "percentage": 77.23 + "covered": 529, + "total": 687, + "percentage": 77 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { @@ -6484,24 +6638,24 @@ }, "src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts": { "statements": { - "covered": 561, - "total": 608, - "percentage": 92.26 + "covered": 564, + "total": 611, + "percentage": 92.3 }, "branches": { - "covered": 59, - "total": 91, - "percentage": 64.83 + "covered": 60, + "total": 92, + "percentage": 65.21 }, "functions": { - "covered": 24, - "total": 25, - "percentage": 96 + "covered": 25, + "total": 26, + "percentage": 96.15 }, "lines": { - "covered": 561, - "total": 608, - "percentage": 92.26 + "covered": 564, + "total": 611, + "percentage": 92.3 } }, "src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts": { @@ -6528,24 +6682,24 @@ }, "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { "statements": { - "covered": 153, - "total": 158, - "percentage": 96.83 + "covered": 171, + "total": 181, + "percentage": 94.47 }, "branches": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 19, + "total": 26, + "percentage": 73.07 }, "functions": { - "covered": 5, - "total": 5, + "covered": 8, + "total": 8, "percentage": 100 }, "lines": { - "covered": 153, - "total": 158, - "percentage": 96.83 + "covered": 171, + "total": 181, + "percentage": 94.47 } }, "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { @@ -6572,31 +6726,53 @@ }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { "statements": { - "covered": 1100, - "total": 1276, - "percentage": 86.2 + "covered": 1112, + "total": 1286, + "percentage": 86.46 }, "branches": { - "covered": 57, - "total": 102, - "percentage": 55.88 + "covered": 58, + "total": 103, + "percentage": 56.31 }, "functions": { - "covered": 25, - "total": 28, - "percentage": 89.28 + "covered": 27, + "total": 29, + "percentage": 93.1 }, "lines": { - "covered": 1100, - "total": 1276, - "percentage": 86.2 + "covered": 1112, + "total": 1286, + "percentage": 86.46 + } + }, + "src/vs/platform/agentHost/node/shared/customizationEnablementGate.ts": { + "statements": { + "covered": 154, + "total": 185, + "percentage": 83.24 + }, + "branches": { + "covered": 47, + "total": 56, + "percentage": 83.92 + }, + "functions": { + "covered": 8, + "total": 9, + "percentage": 88.88 + }, + "lines": { + "covered": 154, + "total": 185, + "percentage": 83.24 } }, "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { "statements": { - "covered": 142, - "total": 373, - "percentage": 38.06 + "covered": 145, + "total": 393, + "percentage": 36.89 }, "branches": { "covered": 6, @@ -6609,9 +6785,9 @@ "percentage": 25 }, "lines": { - "covered": 142, - "total": 373, - "percentage": 38.06 + "covered": 145, + "total": 393, + "percentage": 36.89 } }, "src/vs/platform/agentHost/node/shared/editChunkExtractor.ts": { @@ -6638,9 +6814,9 @@ }, "src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts": { "statements": { - "covered": 246, - "total": 262, - "percentage": 93.89 + "covered": 250, + "total": 266, + "percentage": 93.98 }, "branches": { "covered": 22, @@ -6653,9 +6829,9 @@ "percentage": 62.5 }, "lines": { - "covered": 246, - "total": 262, - "percentage": 93.89 + "covered": 250, + "total": 266, + "percentage": 93.98 } }, "src/vs/platform/agentHost/node/shared/editSurvivalTracker.ts": { @@ -6682,14 +6858,14 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 236, - "total": 250, - "percentage": 94.4 + "covered": 237, + "total": 253, + "percentage": 93.67 }, "branches": { "covered": 36, - "total": 42, - "percentage": 85.71 + "total": 43, + "percentage": 83.72 }, "functions": { "covered": 7, @@ -6697,9 +6873,9 @@ "percentage": 100 }, "lines": { - "covered": 236, - "total": 250, - "percentage": 94.4 + "covered": 237, + "total": 253, + "percentage": 93.67 } }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { @@ -6726,46 +6902,46 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 438, - "total": 528, - "percentage": 82.95 + "covered": 459, + "total": 556, + "percentage": 82.55 }, "branches": { - "covered": 65, - "total": 78, - "percentage": 83.33 + "covered": 83, + "total": 103, + "percentage": 80.58 }, "functions": { - "covered": 21, - "total": 28, - "percentage": 75 + "covered": 25, + "total": 31, + "percentage": 80.64 }, "lines": { - "covered": 438, - "total": 528, - "percentage": 82.95 + "covered": 459, + "total": 556, + "percentage": 82.55 } }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { "statements": { - "covered": 27, - "total": 31, - "percentage": 87.09 + "covered": 48, + "total": 60, + "percentage": 80 }, "branches": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 4, + "total": 5, + "percentage": 80 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 3, + "total": 5, + "percentage": 60 }, "lines": { - "covered": 27, - "total": 31, - "percentage": 87.09 + "covered": 48, + "total": 60, + "percentage": 80 } }, "src/vs/platform/agentHost/node/shared/proxyChatError.ts": { @@ -6814,24 +6990,24 @@ }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 982, - "total": 1085, - "percentage": 90.5 + "covered": 1033, + "total": 1258, + "percentage": 82.11 }, "branches": { - "covered": 147, - "total": 237, - "percentage": 62.02 + "covered": 149, + "total": 241, + "percentage": 61.82 }, "functions": { - "covered": 47, - "total": 49, - "percentage": 95.91 + "covered": 48, + "total": 58, + "percentage": 82.75 }, "lines": { - "covered": 982, - "total": 1085, - "percentage": 90.5 + "covered": 1033, + "total": 1258, + "percentage": 82.11 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -6858,24 +7034,24 @@ }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 819, - "total": 1106, - "percentage": 74.05 + "covered": 856, + "total": 1156, + "percentage": 74.04 }, "branches": { - "covered": 87, - "total": 148, - "percentage": 58.78 + "covered": 91, + "total": 155, + "percentage": 58.7 }, "functions": { - "covered": 37, - "total": 48, - "percentage": 77.08 + "covered": 39, + "total": 50, + "percentage": 78 }, "lines": { - "covered": 819, - "total": 1106, - "percentage": 74.05 + "covered": 856, + "total": 1156, + "percentage": 74.04 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index 347fdde4c70869..e6eb9d769d1888 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -23,7 +23,7 @@ */ import assert from 'assert'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { retry } from '../../../../../../base/common/async.js'; @@ -33,9 +33,9 @@ import { generateUuid } from '../../../../../../base/common/uuid.js'; import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ChangesetOperationTargetKind } from '../../../../common/state/protocol/channels-changeset/commands.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationResult } from '../../../../common/state/protocol/channels-changeset/commands.js'; import { ActionType } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, type SessionState } from '../../../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, readSessionGitState, ROOT_STATE_URI, type SessionState } from '../../../../common/state/sessionState.js'; import { ChangesetKind, buildBranchChangesetUri, @@ -79,6 +79,13 @@ interface IOperationStatusChangedAction { readonly status: string; } +interface IObservedChangesetState { + readonly status: string; + readonly files: readonly IObservedChangesetFile[]; + readonly operations?: readonly IObservedOperation[]; + readonly error?: { readonly message?: string }; +} + const CHANGESET_OPERATION_TIMEOUT_MS = 60_000; export function defineChangesetTests(context: IAgentHostE2ETestContext): void { @@ -105,6 +112,33 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return workspace; } + function createRemoteGitWorkspace(prefix: string): { readonly workspace: string; readonly remote: string } { + const workspace = createGitWorkspace(`${prefix}-workspace-`); + const remote = mkdtempSync(join(tmpdir(), `${prefix}-remote-`)); + tempDirs.push(remote); + execFileSync('git', ['init', '--bare', '-q'], { cwd: remote }); + execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: workspace }); + execFileSync('git', ['push', '-q', '-u', 'origin', 'HEAD'], { cwd: workspace }); + execFileSync('git', ['config', 'pull.rebase', 'false'], { cwd: workspace }); + return { workspace, remote }; + } + + function commitFile(workspace: string, file: string, contents: string, message: string): void { + writeFileSync(join(workspace, file), contents); + execFileSync('git', ['add', file], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', message], { cwd: workspace }); + } + + function pushRemoteCommit(remote: string, prefix: string, file: string, contents: string): void { + const clone = mkdtempSync(join(tmpdir(), `${prefix}-clone-`)); + tempDirs.push(clone); + execFileSync('git', ['clone', '-q', remote, '.'], { cwd: clone }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: clone }); + execFileSync('git', ['config', 'user.name', 'Agent Host E2E'], { cwd: clone }); + commitFile(clone, file, contents, `add ${file}`); + execFileSync('git', ['push', '-q'], { cwd: clone }); + } + async function createSessionIn(workspace: string, prefix: string): Promise { return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); } @@ -200,9 +234,9 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ); } - async function changesetState(channel: string): Promise<{ readonly status: string; readonly files: readonly IObservedChangesetFile[]; readonly error?: { readonly message?: string } }> { + async function changesetState(channel: string): Promise { const result = await context.client.call('subscribe', { channel }); - let state = result.snapshot!.state as { readonly status: string; readonly files: readonly IObservedChangesetFile[]; readonly error?: { readonly message?: string } }; + let state = result.snapshot!.state as IObservedChangesetState; if (state.status === 'computing') { await context.client.waitForNotification(n => isActionNotification(n, 'changeset/statusChanged') @@ -215,6 +249,50 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return state; } + async function waitForOperation(channel: string, operationId: string): Promise { + return retry(async () => { + const operation = (await changesetState(channel)).operations?.find(operation => operation.id === operationId); + if (!operation || operation.status !== 'idle') { + throw new Error(`Changeset ${channel} has not advertised idle operation ${operationId}`); + } + return operation; + }, 100, 100); + } + + async function waitForOperationRemoved(channel: string, operationId: string): Promise { + await retry(async () => { + if ((await changesetState(channel)).operations?.some(operation => operation.id === operationId)) { + throw new Error(`Changeset ${channel} still advertises operation ${operationId}`); + } + }, 100, 100); + } + + async function invokeChangesetOperation(channel: string, operationId: string): Promise<{ + readonly result: InvokeChangesetOperationResult; + readonly statuses: readonly string[]; + }> { + context.client.clearReceived(); + const completed = context.client.waitForNotification(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === channel + && (getActionEnvelope(n).action as IOperationStatusChangedAction).operationId === operationId + && (getActionEnvelope(n).action as IOperationStatusChangedAction).status === 'idle', + CHANGESET_OPERATION_TIMEOUT_MS, + ); + const result = await context.client.call('invokeChangesetOperation', { + channel, + operationId, + }, CHANGESET_OPERATION_TIMEOUT_MS); + await completed; + const statuses = context.client.receivedNotifications(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === channel, + ).map(n => getActionEnvelope(n).action as IOperationStatusChangedAction) + .filter(action => action.operationId === operationId) + .map(action => action.status); + return { result, statuses }; + } + async function waitForChangesetFiles(channel: string, basenames: readonly string[]): Promise { return retry(async () => { const state = await changesetState(channel); @@ -711,6 +789,125 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ]); }); + conformanceTest(context, 'a branch with an upstream and no outgoing commits omits sync', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-none'); + const sessionUri = await createSessionIn(workspace, 'sync-none'); + const changeset = buildUncommittedChangesetUri(sessionUri); + + await retry(async () => { + const subscribed = await context.client.call('subscribe', { channel: sessionUri }); + const gitState = readSessionGitState((subscribed.snapshot!.state as SessionState)._meta); + if (!gitState?.upstreamBranchName || gitState.outgoingChanges !== 0) { + throw new Error('Session Git state has not confirmed an up-to-date upstream'); + } + }, 100, 100); + const state = await changesetState(changeset); + + assert.strictEqual(state.operations?.some(operation => operation.id === 'sync') ?? false, false); + }); + + conformanceTest(context, 'an outgoing commit advertises a changeset-scoped sync operation', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-advertise'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-advertise'); + const changeset = buildUncommittedChangesetUri(sessionUri); + + const operation = await waitForOperation(changeset, 'sync'); + + assert.deepStrictEqual({ + id: operation.id, + scopes: operation.scopes, + status: operation.status, + }, { + id: 'sync', + scopes: ['changeset'], + status: 'idle', + }); + }); + + conformanceTest(context, 'sync pushes an outgoing commit and clears the operation', async function () { + const { workspace, remote } = createRemoteGitWorkspace('ahp-sync-push'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-push'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + + const invoked = await invokeChangesetOperation(changeset, 'sync'); + await waitForOperationRemoved(changeset, 'sync'); + const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(); + const remoteHead = execFileSync('git', ['--git-dir', remote, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + + assert.deepStrictEqual({ + message: typeof invoked.result.message === 'string' ? invoked.result.message : invoked.result.message?.markdown, + statuses: invoked.statuses, + remoteMatchesLocal: remoteHead === localHead, + }, { + message: 'Synced changes.', + statuses: ['running', 'idle'], + remoteMatchesLocal: true, + }); + }); + + conformanceTest(context, 'sync pulls a non-conflicting remote commit before pushing', async function () { + const { workspace, remote } = createRemoteGitWorkspace('ahp-sync-diverged'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + pushRemoteCommit(remote, 'ahp-sync-diverged', 'incoming.txt', 'incoming\n'); + const sessionUri = await createSessionIn(workspace, 'sync-diverged'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + + await invokeChangesetOperation(changeset, 'sync'); + const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(); + const remoteHead = execFileSync('git', ['--git-dir', remote, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + + assert.deepStrictEqual({ + incoming: readFileSync(join(workspace, 'incoming.txt'), 'utf8').replaceAll('\r\n', '\n'), + outgoing: readFileSync(join(workspace, 'outgoing.txt'), 'utf8').replaceAll('\r\n', '\n'), + remoteMatchesLocal: remoteHead === localHead, + }, { + incoming: 'incoming\n', + outgoing: 'outgoing\n', + remoteMatchesLocal: true, + }); + }); + + conformanceTest(context, 'sync reports an error when its upstream becomes unreachable', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-failure'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-failure'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + execFileSync('git', ['remote', 'set-url', 'origin', join(workspace, 'missing-remote')], { cwd: workspace }); + context.client.clearReceived(); + const failed = context.client.waitForNotification(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === changeset + && (getActionEnvelope(n).action as IOperationStatusChangedAction).operationId === 'sync' + && (getActionEnvelope(n).action as IOperationStatusChangedAction).status === 'error', + CHANGESET_OPERATION_TIMEOUT_MS, + ); + + await assert.rejects(context.client.call('invokeChangesetOperation', { + channel: changeset, + operationId: 'sync', + }, CHANGESET_OPERATION_TIMEOUT_MS), /Failed to sync changes/); + await failed; + const statuses = context.client.receivedNotifications(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === changeset, + ).map(n => getActionEnvelope(n).action as IOperationStatusChangedAction) + .filter(action => action.operationId === 'sync') + .map(action => action.status); + + assert.deepStrictEqual({ + statuses, + outgoingPreserved: readFileSync(join(workspace, 'outgoing.txt'), 'utf8').replaceAll('\r\n', '\n'), + }, { + statuses: ['running', 'error'], + outgoingPreserved: 'outgoing\n', + }); + }); + conformanceTest(context, 'discarding a tracked change restores the file and reports operation status', async function () { const { workspace, changeset, file } = await createModifiedUncommittedChangeset('changeset-discard'); const resource = file.edit.after?.uri; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts index 14aaf285c6e073..61bc4a5897f74f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts @@ -475,6 +475,122 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); }); + // The host currently ignores the requested encoding; see KNOWN_ISSUES.md. + conformanceTest(context, 'resourceRead returns requested base64 content without byte loss', async function () { + await initializeClient('resource-read-base64'); + const root = createWorkspace('ahp-resource-read-base64-'); + const bytes = Buffer.from([0, 1, 127, 128, 254, 255]); + writeFileSync(join(root, 'bytes.bin'), bytes); + + const result = await context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: fileUri(root, 'bytes.bin'), + encoding: ContentEncoding.Base64, + }); + + assert.deepStrictEqual({ + encoding: result.encoding, + bytes: [...Buffer.from(result.data, 'base64')], + }, { + encoding: ContentEncoding.Base64, + bytes: [...bytes], + }); + }, context.runHostOnlyKnownIssueTests); + + conformanceTest(context, 'resourceResolve returns versioned file metadata', async function () { + await initializeClient('resource-resolve-metadata'); + const root = createWorkspace('ahp-resource-resolve-metadata-'); + const file = fileUri(root, 'metadata.txt'); + writeFileSync(join(root, 'metadata.txt'), 'metadata'); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: file, + followSymlinks: false, + }); + + assert.deepStrictEqual({ + uri: result.uri, + type: result.type, + size: result.size, + mtimeIsIso: typeof result.mtime === 'string' && new Date(result.mtime).toISOString() === result.mtime, + ctimeIsIso: typeof result.ctime === 'string' && new Date(result.ctime).toISOString() === result.ctime, + hasEtag: typeof result.etag === 'string' && result.etag.length > 0, + }, { + uri: file, + type: ResourceType.File, + size: 'metadata'.length, + mtimeIsIso: true, + ctimeIsIso: true, + hasEtag: true, + }); + }); + + conformanceTest(context, 'resourceWrite createOnly atomically creates a missing file', async function () { + await initializeClient('resource-create-only-new'); + const root = createWorkspace('ahp-resource-create-only-new-'); + const file = fileUri(root, 'created.txt'); + + await writeText(file, 'created', { createOnly: true }); + + assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); + }); + + conformanceTest(context, 'resourceWrite createOnly supports append creation mode', async function () { + await initializeClient('resource-create-only-append'); + const root = createWorkspace('ahp-resource-create-only-append-'); + const file = fileUri(root, 'created.txt'); + + await writeText(file, 'created', { createOnly: true, mode: ResourceWriteMode.Append }); + + assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); + }); + + conformanceTest(context, 'resourceWrite append position beyond EOF prepends content', async function () { + await initializeClient('resource-append-before-start'); + const root = createWorkspace('ahp-resource-append-before-start-'); + const file = fileUri(root, 'append.txt'); + writeFileSync(join(root, 'append.txt'), 'TAIL'); + + await writeText(file, 'HEAD-', { mode: ResourceWriteMode.Append, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'append.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'resourceWrite insert position beyond EOF appends content', async function () { + await initializeClient('resource-insert-after-end'); + const root = createWorkspace('ahp-resource-insert-after-end-'); + const file = fileUri(root, 'insert.txt'); + writeFileSync(join(root, 'insert.txt'), 'HEAD'); + + await writeText(file, '-TAIL', { mode: ResourceWriteMode.Insert, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'insert.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'resourceWrite truncate position beyond EOF appends without padding', async function () { + await initializeClient('resource-truncate-after-end'); + const root = createWorkspace('ahp-resource-truncate-after-end-'); + const file = fileUri(root, 'truncate.txt'); + writeFileSync(join(root, 'truncate.txt'), 'HEAD'); + + await writeText(file, '-TAIL', { mode: ResourceWriteMode.Truncate, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'truncate.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'concurrent resourceWrite appends preserve every write', async function () { + await initializeClient('resource-append-concurrent'); + const root = createWorkspace('ahp-resource-append-concurrent-'); + const file = fileUri(root, 'append.txt'); + writeFileSync(join(root, 'append.txt'), ''); + const pieces = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + + await Promise.all(pieces.map(piece => writeText(file, piece, { mode: ResourceWriteMode.Append }))); + + assert.deepStrictEqual([...readFileSync(join(root, 'append.txt'), 'utf8')].sort(), pieces); + }); + conformanceTest(context, 'resourceWrite accepts the current etag', async function () { await initializeClient('resource-if-match-current'); const root = createWorkspace('ahp-resource-if-match-current-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index c659373f6a686c..8a0c18181121f8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -71,6 +71,22 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { return result.snapshot!.state as ChatState; } + async function chatCompletions(chatUri: string, text: string): Promise { + return context.client.call('completions', { + channel: chatUri, + kind: CompletionItemKind.UserMessage, + text, + offset: text.length, + }); + } + + async function createCompletedPeer(sessionUri: string, id: string, title: string, turnId = `turn-${id}`): Promise { + const peer = await createPeer(sessionUri, id); + await context.client.call('subscribe', { channel: peer }); + await driveTurn(peer, turnId, `/rename ${title}`, 1); + return peer; + } + async function rename(channel: string, title: string, clientSeq = 1): Promise { context.client.clearReceived(); context.client.dispatch({ @@ -394,6 +410,130 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { assert.deepStrictEqual(completions.items.map(item => item.insertText), ['@peer-target.txt']); }, config.supportsMultipleChats); + conformanceTest(context, 'chat completion references a peer at its last completed turn', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-basic'); + const peer = await createCompletedPeer(sessionUri, 'peer', 'Referenced Peer', 'peer-reference-turn'); + + const completions = await chatCompletions(defaultChatUri, '#chat:'); + + assert.deepStrictEqual(completions.items, [{ + insertText: '#chat:Referenced Peer ', + rangeStart: 0, + rangeEnd: 6, + attachment: { + type: MessageAttachmentKind.Chat, + resource: peer, + endTurn: 'peer-reference-turn', + label: 'Referenced Peer', + }, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion filters peer titles case-insensitively', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-filter'); + const target = await createCompletedPeer(sessionUri, 'target', 'Alpha Target'); + await createCompletedPeer(sessionUri, 'other', 'Beta Reference'); + + const completions = await chatCompletions(defaultChatUri, '#chat:tArGeT'); + + assert.deepStrictEqual(completions.items.map(item => item.attachment), [{ + type: MessageAttachmentKind.Chat, + resource: target, + endTurn: 'turn-target', + label: 'Alpha Target', + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'partial chat prefix offers completed peer chats', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-partial'); + await createCompletedPeer(sessionUri, 'peer', 'Partial Peer'); + + const completions = await chatCompletions(defaultChatUri, '#ch'); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '#chat:Partial Peer ', + rangeStart: 0, + rangeEnd: 3, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion replaces only the whitespace-delimited token', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-range'); + await createCompletedPeer(sessionUri, 'peer', 'Range Peer'); + const text = 'Compare with #chat:Range'; + + const completions = await chatCompletions(defaultChatUri, text); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '#chat:Range Peer ', + rangeStart: 'Compare with '.length, + rangeEnd: text.length, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion excludes peers without a completed turn', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-empty'); + await createPeer(sessionUri, 'empty'); + const completed = await createCompletedPeer(sessionUri, 'completed', 'Completed Peer'); + + const completions = await chatCompletions(defaultChatUri, '#chat:'); + + assert.deepStrictEqual(completions.items.map(item => + item.attachment?.type === MessageAttachmentKind.Chat ? item.attachment.resource : undefined + ), [completed]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion excludes the chat being edited', async function () { + const { sessionUri } = await createSession('chat-completion-current'); + const current = await createCompletedPeer(sessionUri, 'current', 'Current Peer'); + const sibling = await createCompletedPeer(sessionUri, 'sibling', 'Sibling Peer'); + + const completions = await chatCompletions(current, '#chat:'); + + assert.deepStrictEqual(completions.items.map(item => + item.attachment?.type === MessageAttachmentKind.Chat ? item.attachment.resource : undefined + ), [sibling]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'peer chat completion can reference the completed default chat', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-default'); + await driveTurn(defaultChatUri, 'default-reference-turn', '/rename Default Reference', 1); + const peer = await createPeer(sessionUri, 'peer'); + + const completions = await chatCompletions(peer, '#chat:Default'); + + assert.deepStrictEqual(completions.items.map(item => item.attachment), [{ + type: MessageAttachmentKind.Chat, + resource: defaultChatUri, + endTurn: 'default-reference-turn', + label: 'Default Reference', + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion sanitizes multiline titles', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-title'); + const peer = await createCompletedPeer(sessionUri, 'peer', 'Initial Title'); + await rename(peer, 'Line One\n Line Two', 2); + + const completions = await chatCompletions(defaultChatUri, '#chat:Line'); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + label: item.attachment?.label, + })), [{ + insertText: '#chat:Line One Line Two ', + label: 'Line One Line Two', + }]); + }, config.supportsMultipleChats); + conformanceTest(context, 'first peer chat snapshots the session title onto the default chat', async function () { const { sessionUri, defaultChatUri } = await createSession('default-title'); await rename(sessionUri, 'Original Session'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index c50bb4a36e16f5..2425ce25dd8d02 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; -import { retry } from '../../../../../../base/common/async.js'; +import { retry, timeout } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; @@ -65,14 +65,18 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) })); } - async function releaseAndRestoreSession(sessionUri: string): Promise { + async function releaseAndRestoreSession(sessionUri: string, additionalChats: readonly string[] = []): Promise { const before = await fetchSessionWithChat(context.client, sessionUri); const beforeResponsePartIds = responsePartIds(before.turns); const beforeTurns = durableTurnContent(before.turns); assert.ok(beforeResponsePartIds.length > 0); const chatUri = buildDefaultChatUri(sessionUri); + for (const chat of additionalChats) { + context.client.notify('unsubscribe', { channel: chat }); + } context.client.notify('unsubscribe', { channel: chatUri }); context.client.notify('unsubscribe', { channel: sessionUri }); + await timeout(50); await retry(async () => { const restored = await fetchSessionWithChat(context.client, sessionUri); @@ -156,7 +160,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) 60_000, ); - await releaseAndRestoreSession(sessionUri); + await releaseAndRestoreSession(sessionUri, [peerUri]); await restartAndInitialize(`peer-persistence-reconnect-${config.provider}`, workspace); const reopenedSession = await context.client.call('subscribe', { channel: sessionUri }); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts index e68143b9e45af0..3fbdd3abab6b61 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts @@ -8,14 +8,19 @@ */ import assert from 'assert'; -import { mkdtemp, rm } from 'fs/promises'; +import { existsSync } from 'fs'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { timeout } from '../../../../../base/common/async.js'; +import { join } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; -import { buildDefaultChatUri, ResponsePartKind, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ActionType, type ChatToolCallCompleteAction, type ChatToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import { buildDefaultChatUri, ResponsePartKind, SessionStatus, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ToolCallConfirmationReason } from '../../../common/state/protocol/channels-chat/state.js'; import { AgentHostSessionReleaseGraceMsEnvVar } from '../../../common/agentService.js'; import { createProviderSession, dispatchTurn, type IAgentHostProviderTestConfig } from '../providerIntegrationTestHelpers.js'; -import { fetchSessionWithChat, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from '../serverIntegrationTestHelpers.js'; +import { fetchSessionWithChat, getActionEnvelope, isActionNotification, IServerHandle, startRealServer, stopServer, TestProtocolClient } from '../serverIntegrationTestHelpers.js'; const COPILOT_CONFIG: IAgentHostProviderTestConfig = { provider: 'copilotcli', @@ -23,6 +28,13 @@ const COPILOT_CONFIG: IAgentHostProviderTestConfig = { githubToken: 'not-a-real-token', }; +const DETACHED_SHELL_SCENARIO_ID = 'detached-shell-idle-release'; +const DETACHED_SHELL_DELAY_MS = 6000; + +function quoteShellArgument(value: string): string { + return isWindows ? `'${value.replace(/'/g, '\'\'')}'` : `'${value.replace(/'/g, `'\\''`)}'`; +} + suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { let server: IServerHandle; @@ -104,16 +116,51 @@ suite('Agent Host Provider Integration — Copilot Idle Release', function () { let server: IServerHandle; let client: TestProtocolClient; + let suiteHome: string; + let detachedCompletionMarker: string; const createdSessions: string[] = []; const tempDirs: string[] = []; suiteSetup(async function () { this.timeout(120_000); - server = await startRealServer({ mockLlm: true, env: { [AgentHostSessionReleaseGraceMsEnvVar]: String(RELEASE_GRACE_MS) } }); + suiteHome = await mkdtemp(`${tmpdir()}/test-mock-idle-release-home`); + detachedCompletionMarker = join(suiteHome, 'detached-shell-complete'); + const detachedScript = join(suiteHome, 'detached-shell.js'); + await writeFile(detachedScript, `setTimeout(() => require('fs').writeFileSync(${JSON.stringify(detachedCompletionMarker)}, 'done'), ${DETACHED_SHELL_DELAY_MS});`); + const command = `node ${quoteShellArgument(detachedScript)}`; + server = await startRealServer({ + mockLlm: true, + homeDir: suiteHome, + userDataDir: join(suiteHome, 'user-data'), + env: { [AgentHostSessionReleaseGraceMsEnvVar]: String(RELEASE_GRACE_MS) }, + mockScenarios: [{ + id: DETACHED_SHELL_SCENARIO_ID, + definition: { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: [{ + toolNamePattern: /^(bash|powershell)$/, + arguments: { + command, + description: 'Run detached shell release probe', + mode: 'async', + detach: true, + initial_wait: 30, + }, + }], + }, + { kind: 'content', chunks: [{ content: 'Waiting for detached shell completion.', delayMs: 0 }] }, + ], + }, + }], + }); }); - suiteTeardown(function () { - server?.process.kill(); + suiteTeardown(async function () { + await stopServer(server); + await rm(suiteHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }); setup(async function () { @@ -139,6 +186,59 @@ suite('Agent Host Provider Integration — Copilot Idle Release', function () { tempDirs.length = 0; }); + test('keeps a detached shell running after an idle session loses all subscribers (mock LLM)', async function () { + this.timeout(180_000); + + const workspaceDir = await mkdtemp(`${tmpdir()}/test-mock-detached-release`); + tempDirs.push(workspaceDir); + const sessionUri = await createProviderSession(client, COPILOT_CONFIG, 'real-sdk-mock-detached-release', createdSessions, URI.file(workspaceDir)); + const turnId = 'turn-detached-release'; + + dispatchTurn(client, sessionUri, turnId, `[scenario:${DETACHED_SHELL_SCENARIO_ID}] Start the detached shell.`, 1); + const readyNotification = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallReady')) { + return false; + } + return !(getActionEnvelope(n).action as ChatToolCallReadyAction).confirmed; + }, 90_000); + const readyEnvelope = getActionEnvelope(readyNotification); + const readyAction = readyEnvelope.action as ChatToolCallReadyAction; + client.dispatch({ + channel: readyEnvelope.channel, + clientSeq: 2, + action: { + type: ActionType.ChatToolCallConfirmed, + turnId: readyAction.turnId, + toolCallId: readyAction.toolCallId, + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }, + }); + const completeNotification = await client.waitForNotification(n => isActionNotification(n, 'chat/toolCallComplete'), 90_000); + const completeAction = getActionEnvelope(completeNotification).action as ChatToolCallCompleteAction; + assert.match(JSON.stringify(completeAction.result), /detached background/); + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + + const idle = await fetchSessionWithChat(client, sessionUri); + assert.deepStrictEqual({ + activeTurn: idle.activeTurn, + inProgress: (idle.status & SessionStatus.InProgress) !== 0, + }, { + activeTurn: undefined, + inProgress: false, + }); + + for (const channel of [buildDefaultChatUri(sessionUri), sessionUri]) { + client.notify('unsubscribe', { channel }); + } + await timeout(RELEASE_GRACE_MS + 1000); + + for (let attempt = 0; attempt < 150 && !existsSync(detachedCompletionMarker); attempt++) { + await timeout(100); + } + assert.strictEqual(await readFile(detachedCompletionMarker, 'utf8'), 'done'); + }); + test('releases an idle session and resumes it losslessly on re-subscribe (mock LLM)', async function () { this.timeout(180_000); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 069c063f0910ea..06a5b96bc25dc3 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, thenRegisterOrDispose, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { Schemas } from '../../../../base/common/network.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; @@ -21,8 +21,9 @@ import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../ import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; -import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { EDITOR_FONT_DEFAULTS } from '../../../../editor/common/config/fontInfo.js'; import { EditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; @@ -356,7 +357,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Input private _editor!: CodeEditorWidget; private _editorContainer!: HTMLElement; - private readonly _inputModelReference = this._register(new MutableDisposable>()); private _sessionControlsContainer: HTMLElement | undefined; private readonly _promptTemplatePlaceholder = this._register(new MutableDisposable()); private readonly _promptOptionsWidget = this._register(new MutableDisposable()); @@ -702,12 +702,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation * input model and force-destroys it when the last reference is released. * Holding one keeps the model alive for this editor's lifetime. */ - private async _holdInputModelReference(uri: URI): Promise { - try { - this._inputModelReference.value = await this.textModelService.createModelReference(uri); - } catch (error) { - this.logService.error('Failed to hold the chat input model reference', error); - } + private _holdInputModelReference(uri: URI, model: ITextModel): void { + const inputModelReference = thenRegisterOrDispose(this.textModelService.createModelReference(uri), this._store); + void inputModelReference.catch(error => { + model.dispose(); + if (!this._store.isDisposed) { + this.logService.error('Failed to hold the chat input model reference', error); + } + }); } private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { @@ -725,8 +727,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); const uri = URI.from({ scheme: Schemas.sessionsChatInput, path: `input-${Date.now()}` }); - const textModel = this._register(this.modelService.createModel('', null, uri, true)); - void this._holdInputModelReference(uri); + const textModel = this.modelService.createModel('', null, uri, true); + this._holdInputModelReference(uri, textModel); const editorOptions: IEditorConstructionOptions = { ...getSimpleEditorOptions(this.configurationService), diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts new file mode 100644 index 00000000000000..ebbfff217729cf --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IResolvedTextEditorModel } from '../../../../../editor/common/services/resolverService.js'; +import { NewChatInputWidget } from '../../browser/newChatInput.js'; + +interface IInputModelReferenceHarness { + readonly _store: DisposableStore; + readonly textModelService: { + createModelReference(resource: URI): Promise>; + }; + readonly logService: { + error(message: string, error: Error): void; + }; + _register(disposable: T): T; +} + +const holdInputModelReference = Reflect.get(NewChatInputWidget.prototype, '_holdInputModelReference') as (this: IInputModelReferenceHarness, uri: URI, model: ITextModel) => void; + +class InputModelReferenceHarness implements IInputModelReferenceHarness, IDisposable { + readonly _store = new DisposableStore(); + + constructor( + readonly textModelService: IInputModelReferenceHarness['textModelService'], + readonly logService: IInputModelReferenceHarness['logService'], + ) { } + + _register(disposable: T): T { + return this._store.add(disposable); + } + + dispose(): void { + this._store.dispose(); + } +} + +suite('NewChatInputWidget', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps the input model alive until reference acquisition settles during disposal', async () => { + const referenceDeferred = new DeferredPromise>(); + let modelDisposed = false; + let referenceDisposed = false; + const errors: { message: string; error: Error }[] = []; + const model = new class extends mock() { + override dispose(): void { + modelDisposed = true; + } + }(); + const resolvedModel = new class extends mock() { + override readonly textEditorModel = model; + }(); + const harness = disposables.add(new InputModelReferenceHarness( + { + createModelReference: () => referenceDeferred.p, + }, + { + error: (message, error) => errors.push({ message, error }), + }, + )); + + holdInputModelReference.call(harness, URI.from({ scheme: Schemas.sessionsChatInput, path: 'input-test' }), model); + harness.dispose(); + const disposedBeforeReferenceSettled = modelDisposed; + + referenceDeferred.complete({ + object: resolvedModel, + dispose: () => { + referenceDisposed = true; + model.dispose(); + }, + }); + await referenceDeferred.p; + await Promise.resolve(); + + assert.deepStrictEqual({ + disposedBeforeReferenceSettled, + modelDisposed, + referenceDisposed, + errors, + }, { + disposedBeforeReferenceSettled: false, + modelDisposed: true, + referenceDisposed: true, + errors: [], + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 425fa2ff313b3e..03c2e996285556 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -107,7 +107,7 @@ A single agent host session uses several distinct identifiers: To avoid an empty list on window startup — before the agent host has started, authentication has settled, and the first `listSessions()` round-trip returns — the base provider persists a lightweight snapshot of each session summary to `IStorageService` and re-hydrates it on the next launch. This machinery lives in `BaseAgentHostSessionsProvider` and is **shared by both the local and remote providers**: - A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list. -- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. +- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived`, aggregate change counts, and the bounded, validated GitHub summary used for pull-request presentation — from each adapter), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. - Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile }`. `workspaceFile` is the complete workspace configuration URI string; the Agent Host persists the validated object as JSON under the `multiRoot` session-database key, reconstructs it during listing/restoration, and the startup cache preserves it before the first live listing. The Editor session list matches this URI directly against `IWorkspace.configuration`; metadata-less sessions use current-folder containment without a separate workspace membership memento. - Multi-root new-session **Folder-picker** decisions are provider-owned and carried in `_meta` under the `vscode.folderPicker` key as `{ hidden, primary? }`. The owning agent computes it (`IAgent.computeFolderPickerDecision`) from the ordered working-directory set when a fresh (non-fork, non-import) multi-root session is created; `AgentService` seeds it into the session `_meta`, persists the validated object as JSON under the `vscode.folderPicker` session-database key, and reconstructs it during listing/restoration so the decision is a frozen creation-time fact (hidden stays hidden on reopen, shown stays shown). The client keeps the picker hidden by default and reveals it only when `hidden` is `false`, auto-selecting `primary` (a working-directory URI string, valid only on a hidden, pinned decision) before the session starts. A provider that expresses no opinion returns `undefined`, so nothing is seeded and the picker stays hidden. - Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 480b0381952745..2b0f81ad6ea851 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -89,9 +89,8 @@ const CACHED_SESSIONS_MAX_PER_HOST = 100; /** * Serialized shape of an {@link IAgentSessionMetadata} suitable for - * persisting via {@link IStorageService}. URIs are stored as strings - * and diffs are intentionally omitted (they are re-populated when the - * connection refreshes sessions). + * persisting via {@link IStorageService}. URIs are stored as strings and only + * lightweight metadata needed to render the session list is retained. */ interface ISerializedSessionMetadata { readonly session: string; @@ -108,6 +107,8 @@ interface ISerializedSessionMetadata { /** @deprecated Legacy name for `isArchived`. */ readonly isDone?: boolean; readonly project?: { readonly uri: string; readonly displayName: string }; + readonly changes?: ChangesSummary; + readonly github?: ISessionGitHubState; /** * Whether the session is a workspace-less quick chat. Persisted because the * adapter seeds its session-kind from this tag at construction (see @@ -135,6 +136,8 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad workingDirectory: meta.workingDirectories?.[0]?.toString(), status: meta.status !== undefined ? meta.status & SESSION_STATUS_FLAG_MASK : undefined, project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + changes: meta.changes, + github: readSessionGitHubState(meta._meta), workspaceless: readSessionWorkspaceless(meta._meta) || undefined, external: readSessionExternal(meta._meta) || undefined, multiRoot: readSessionMultiRootMetadata(meta._meta), @@ -146,6 +149,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta let _meta = withSessionWorkspaceless(undefined, raw.workspaceless === true); _meta = withSessionExternal(_meta, raw.external === true); _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: raw.multiRoot })); + _meta = withSessionGitHubState(_meta, raw.github); return { session: URI.parse(raw.session), startTime: raw.startTime, @@ -154,6 +158,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta workingDirectories: raw.workingDirectory ? [URI.parse(raw.workingDirectory)] : undefined, status: deserializeStatus(raw), project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + changes: raw.changes, ...(_meta ? { _meta } : {}), }; } catch { @@ -675,6 +680,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // `reconcileSelectedAgent`). private _agentBaseDir: URI | undefined; private _meta: SessionMeta | undefined; + /** The latest session metadata used to build startup-cache presentation state. */ + get sessionMeta(): SessionMeta | undefined { return this._meta; } /** * Whether this session is a workspace-less quick chat. Seeded from the * constructor metadata and only ever promoted by @@ -1363,11 +1370,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * yet. */ setMeta(meta: SessionMeta | undefined, tx?: ITransaction): boolean { + const metaChanged = !equals(this._meta, meta); this._meta = meta; - let didChange = false; + let didChange = metaChanged; subtransaction(tx, tx => { this._metaObs.set(this._meta, tx); - didChange = this._promoteToQuickChatIfWorkspaceless(tx); + if (this._promoteToQuickChatIfWorkspaceless(tx)) { + didChange = true; + } const workspace = this._computeWorkspace(); if (this._setWorkspace(workspace, tx)) { didChange = true; @@ -4818,20 +4828,22 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!base) { continue; } + const sessionMeta = adapter.isQuickChat.get() + ? withSessionWorkspaceless(adapter.sessionMeta, true) + : adapter.sessionMeta; entries.push(serializeMetadata({ ...base, summary: adapter.title.get() || base.summary, modifiedTime: adapter.updatedAt.get().getTime(), + changes: adapter.changesSummary.get(), // A project assigned by `backfillProject` lives only on the adapter. project: adapter.project ?? base.project, status: withSessionStatusFlag( withSessionStatusFlag(base.status ?? ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsRead, adapter.isRead.get()), ProtocolSessionStatus.IsArchived, adapter.isArchived.get()), - // The adapter's live kind wins over the snapshot: several metadata - // sources omit `_meta`, and persisting a stale one would resurrect - // the session as a workspace rooted at the host's scratch cwd. - ...(adapter.isQuickChat.get() ? { _meta: withSessionWorkspaceless(base._meta, true) } : {}), + // Session-state updates can refine presentation metadata without another listing. + _meta: sessionMeta, })); } if (entries.length === 0) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index ac95e75117f5f3..87d17d5cfeb24c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -1289,6 +1289,79 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrates persisted change stats before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.addSession(createSession('cached-metadata', { summary: 'Cached Metadata' })); + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + + fireSessionSummaryChanged(previousHost, 'cached-metadata', { + changes: { additions: 12, deletions: 4, files: 3 }, + }); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService }); + const listSessionsCallsBeforeRead = nextHost.listSessionsCallCount; + const restored = nextProvider.getSessions()[0]; + + assert.deepStrictEqual({ + listSessionsCallsBeforeRead, + changesSummary: restored.changesSummary?.get(), + }, { + listSessionsCallsBeforeRead: 0, + changesSummary: { additions: 12, deletions: 4, files: 3 }, + }); + })); + + test('hydrates a pull request icon persisted by a metadata-only update', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.addSession(createSession('cached-pr', { + summary: 'Cached PR', + project: { uri: URI.file('/repo'), displayName: 'repo' }, + })); + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + + fireSessionSummaryChanged(previousHost, 'cached-pr', { + _meta: withSessionGitHubState(undefined, { + owner: 'owner', + repo: 'repo', + pullRequestUrls: ['https://github.com/owner/repo/pull/42'], + pullRequestBranchName: 'feature', + }), + }); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const gitHubService = new class extends mock() { + private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel; + override createPullRequestModelReference = () => new ImmortalReference(this._model); + }(); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService, gitHubService }); + const restored = nextProvider.getSessions()[0]; + const pullRequestIcon = restored.completedStateIcon?.get(); + + assert.deepStrictEqual({ + pullRequestIcon: pullRequestIcon && { id: pullRequestIcon.id, color: pullRequestIcon.color?.id }, + }, { + pullRequestIcon: { + id: computePullRequestIcon(GitHubPullRequestState.Open).id, + color: computePullRequestIcon(GitHubPullRequestState.Open).color?.id, + }, + }); + })); + test('discards a legacy cache entry so read state is rebuilt from the host', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Storage-key literals of the pre-`.v2` cache schema, whose entries // carried a stale `isRead: true` written by the old always-read adapter. diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index 523748df685bc1..f3ef66bbe3e920 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -76,13 +76,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } if (markCapability && !skipEmptyCommands) { - let next = markCapability.markers().next()?.value; - const arr: IMarker[] = []; - while (next) { - arr.push(next); - next = markCapability.markers().next()?.value; - } - markers = arr; + markers = [...markCapability.markers()]; } return markers; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts new file mode 100644 index 00000000000000..b9870b7c9e9a98 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IMarker, Terminal } from '@xterm/xterm'; +import { strictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { BufferMarkCapability } from '../../../../../../platform/terminal/common/capabilities/bufferMarkCapability.js'; +import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import { TestXtermLogger } from '../../../../../../platform/terminal/test/common/terminalTestHelpers.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { MarkNavigationAddon } from '../../../browser/xterm/markNavigationAddon.js'; + +class TestBufferMarkCapability extends BufferMarkCapability { + markerIteratorCalls = 0; + + override markers(): IterableIterator { + this.markerIteratorCalls++; + if (this.markerIteratorCalls > 2) { + throw new Error('Buffer mark iterator was requested repeatedly'); + } + return super.markers(); + } +} + +suite('MarkNavigationAddon', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('should consume buffer mark iterators when navigating', async () => { + const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + const terminal: Terminal = store.add(new TerminalCtor({ + allowProposedApi: true, + cols: 80, + rows: 30, + logger: TestXtermLogger + })); + const capabilities = store.add(new TerminalCapabilityStore()); + const markCapability = store.add(new TestBufferMarkCapability(terminal)); + markCapability.addMark(); + capabilities.add(TerminalCapability.BufferMarkDetection, markCapability); + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService() + }, store); + const addon = store.add(instantiationService.createInstance(MarkNavigationAddon, capabilities)); + terminal.loadAddon(addon); + + addon.scrollToNextMark(undefined, undefined, false); + + strictEqual(markCapability.markerIteratorCalls, 2); + }); +});