From 21706550d0fde5b9fea91ff67b1416502cb2f295 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:39:34 -0700 Subject: [PATCH 01/13] Use proper functions to get chat session types We shouldn't look at the scheme directly --- .../api/browser/mainThreadChatSessions.ts | 7 ++++--- .../workbench/api/common/extHostChatSessions.ts | 15 ++++++++------- .../chat/browser/actions/chatForkActions.ts | 5 +++-- .../agentHost/agentHostSessionHandler.ts | 3 ++- .../browser/agentSessions/agentSessionsOpener.ts | 7 ++++--- .../browser/chatEditing/chatEditingServiceImpl.ts | 3 ++- .../chatSessions/chatSessions.contribution.ts | 10 +++++----- .../chat/browser/widget/chatListRenderer.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 4 ++-- .../chat/browser/widgetHosts/editor/chatEditor.ts | 2 +- .../contrib/chat/common/chatDebugServiceImpl.ts | 11 ++++++----- .../chat/common/chatService/chatServiceImpl.ts | 7 +++---- .../common/chatService/chatServiceTelemetry.ts | 4 ++-- .../test/common/chatService/chatService.test.ts | 2 +- .../chat/test/common/mockChatSessionsService.ts | 6 ++++-- 15 files changed, 48 insertions(+), 40 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 09acdfa10143d..8aad081c4d868 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -690,12 +690,13 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._register(this._chatSessionsService.onDidChangeSessionOptions(({ sessionResource, updates }) => { warnOnUntitledSessionResource(sessionResource, this._logService); - const handle = this._getHandleForSessionType(sessionResource.scheme); - this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.size} update(s)`); + const sessionType = getChatSessionType(sessionResource); + const handle = this._getHandleForSessionType(sessionType); + this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: sessionType '${sessionType}', handle ${handle}, ${updates.size} update(s)`); if (handle !== undefined) { this.notifyOptionsChange(handle, sessionResource, updates); } else { - this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for scheme '${sessionResource.scheme}': no provider registered. Registered schemes: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); + this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for sessionType '${sessionType}': no provider registered. Registered types: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); } })); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 7d0cd091eae0c..37b93c35b4902 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -21,7 +21,7 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; import { ChatSessionContentContextDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatNewSessionRequestDto, IChatSessionDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; @@ -651,7 +651,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const sessionResource = URI.revive(sessionResourceComponents); - const controllerData = this.getChatSessionItemController(sessionResource.scheme); + const controllerData = this.getChatSessionItemController(getChatSessionType(sessionResource)); let inputState: vscode.ChatSessionInputState; if (controllerData?.controller.getChatSessionInputState) { const result = await controllerData.controller.getChatSessionInputState(isUntitledChatSession(sessionResource) ? undefined : sessionResource, { @@ -754,9 +754,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return; } - const controllerData = this.getChatSessionItemController(sessionResource.scheme); + const sessionType = getChatSessionType(sessionResource); + const controllerData = this.getChatSessionItemController(sessionType); if (!controllerData || !controllerData.controller.getChatSessionInputState) { - this._logService.warn(`No valid controller found for scheme ${sessionResource.scheme}`); + this._logService.warn(`No valid controller found for session type ${sessionType}`); return; } @@ -866,7 +867,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const requestTurn = this.convertRequestDtoToRequestTurn(request); - const controllerData = this.getChatSessionItemController(sessionResource.scheme); + const controllerData = this.getChatSessionItemController(getChatSessionType(sessionResource)); if (controllerData?.controller.forkHandler) { const item = await controllerData.controller.forkHandler(sessionResource, requestTurn, token); return typeConvert.ChatSessionItem.from(item); @@ -949,8 +950,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio initialSessionOptions: ReadonlyArray<{ optionId: string; value: string }> | undefined, token: CancellationToken, ): Promise { - const scheme = sessionResource?.scheme; - const controllerData = scheme ? this.getChatSessionItemController(scheme) : undefined; + const sessionType = sessionResource ? getChatSessionType(sessionResource) : undefined; + const controllerData = sessionType ? this.getChatSessionItemController(sessionType) : undefined; const resolvedResource = sessionResource && !isUntitledChatSession(sessionResource) ? sessionResource : undefined; if (controllerData?.controller.getChatSessionInputState) { const result = await controllerData.controller.getChatSessionInputState( diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 92d03344044d4..39e4afbce1fd5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -17,6 +17,7 @@ import { IChatService, ResponseModelState } from '../../common/chatService/chatS import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; @@ -62,7 +63,7 @@ export function registerChatForkActions() { // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); - if (contentProviderSchemes.includes(sourceSessionResource.scheme)) { + if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService); } @@ -142,7 +143,7 @@ export function registerChatForkActions() { // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); - if (contentProviderSchemes.includes(sessionResource.scheme)) { + if (contentProviderSchemes.includes(getChatSessionType(sessionResource))) { const contributedSession = await chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); let request = contributedSession.history.find((entry): entry is IChatSessionRequestHistoryItem => entry.type === 'request' && entry.id === targetRequestId); if (!request) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c20cfa590d6f7..215f9fac15d62 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -34,6 +34,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IChatWidgetService } from '../../chat.js'; import { ChatRequestQueueKind, ConfirmedReason, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; @@ -2297,7 +2298,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!rawModelId) { return undefined; } - const prefix = `${sessionResource.scheme}:`; + const prefix = `${getChatSessionType(sessionResource)}:`; return rawModelId.startsWith(prefix) ? rawModelId : `${prefix}${rawModelId}`; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 52b0707f567ba..f4faa80531e99 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -10,8 +10,9 @@ import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatViewPaneTarget, IChatWidget, IChatWidgetService } from '../chat.js'; import { ACTIVE_GROUP, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; -import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { localize } from '../../../../../nls.js'; @@ -102,8 +103,8 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes target = ChatViewPaneTarget; } - const isLocalChatSession = session.resource.scheme === Schemas.vscodeChatEditor || session.resource.scheme === Schemas.vscodeLocalChatSession; - if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(session.resource.scheme))) { + const isLocalChatSession = session.resource.scheme === Schemas.vscodeChatEditor || getChatSessionType(session.resource) === localChatSessionType; + if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(getChatSessionType(session.resource)))) { target = openOptions?.sideBySide ? SIDE_GROUP : ACTIVE_GROUP; // force to open in editor if session cannot be resolved in panel options = { ...options, revealIfOpened: true }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index aeb355759dfc0..4f7446e4736b1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts @@ -40,6 +40,7 @@ import { INotebookService } from '../../../notebook/common/notebookService.js'; import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, IChatEditingService, IChatEditingSession, IChatEditingSessionProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/editing/chatEditingService.js'; import { ChatModel, ICellTextEditOperation, IChatResponseModel, isCellTextEditOperationArray } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingSession } from './chatEditingSession.js'; @@ -173,7 +174,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic assertType(this.getEditingSession(chatModel.sessionResource) === undefined, 'CANNOT have more than one editing session per chat session'); - const provider = this._providers.get(chatModel.sessionResource.scheme); + const provider = this._providers.get(getChatSessionType(chatModel.sessionResource)); const session = provider ? provider.createEditingSession(chatModel.sessionResource) : this._instantiationService.createInstance(ChatEditingSession, chatModel.sessionResource, global, this._lookupEntry.bind(this), initFrom); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 089c3035e2b8f..05df1a613390f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -14,7 +14,6 @@ import { Schemas } from '../../../../../base/common/network.js'; import * as resources from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, IMenuService, MenuId, MenuItemAction, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -44,7 +43,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -1035,7 +1034,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - if (!(await raceCancellationError(this.canResolveChatSession(sessionResource.scheme), token))) { + const sessionType = getChatSessionType(sessionResource); + if (!(await raceCancellationError(this.canResolveChatSession(sessionType), token))) { throw Error(`Can not find provider for ${sessionResource}`); } @@ -1047,7 +1047,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - const resolvedType = this._resolveToPrimaryType(sessionResource.scheme) || sessionResource.scheme; + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const provider = this._contentProviders.get(resolvedType); if (!provider) { throw Error(`Can not find provider for ${sessionResource}`); @@ -1089,7 +1089,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - const sessionData = new ContributedChatSessionData(session, sessionResource.scheme, sessionResource, session.options, resource => { + const sessionData = new ContributedChatSessionData(session, sessionType, sessionResource, session.options, resource => { sessionData.dispose(); this._sessions.delete(resource); }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index d7bdb0c171492..5d706a50a381b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1915,7 +1915,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { // In the regular workbench, only archive local chat sessions. // In the sessions window, allow archiving any session type after delegation. - if (sessionResource.scheme !== Schemas.vscodeLocalChatSession && !IsSessionsWindowContext.getValue(this.contextKeyService)) { + if (getChatSessionType(sessionResource) !== localChatSessionType && !IsSessionsWindowContext.getValue(this.contextKeyService)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index 688c8c6beec6b..7b8cf8a376680 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -227,7 +227,7 @@ export class ChatEditor extends AbstractEditorWithViewState c.type === chatSessionType); if (contribution) { diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 84f6c3b2f8a38..5ddd10f2805c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -12,7 +12,8 @@ import { ResourceMap } from '../../../../base/common/map.js'; import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; -import { LocalChatSessionUri } from './model/chatUri.js'; +import { localChatSessionType } from './chatSessionsService.js'; +import { getChatSessionType } from './model/chatUri.js'; /** * Per-session circular buffer for debug events. @@ -137,15 +138,15 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic generic: 5, }; - /** Schemes eligible for debug logging and provider invocation. */ - private static readonly _debugEligibleSchemes = new Set([ - LocalChatSessionUri.scheme, // vscode-chat-session (local sessions) + /** Session types eligible for debug logging and provider invocation. */ + private static readonly _debugEligibleSessionTypes = new Set([ + localChatSessionType, // local sessions 'copilotcli', // Copilot CLI background sessions 'claude-code', // Claude Code CLI sessions ]); private _isDebugEligibleSession(sessionResource: URI): boolean { - return ChatDebugServiceImpl._debugEligibleSchemes.has(sessionResource.scheme) + return ChatDebugServiceImpl._debugEligibleSessionTypes.has(getChatSessionType(sessionResource)) || this._importedSessions.has(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 733c6d88adec3..b2c04973cd21b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -12,7 +12,6 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { Schemas } from '../../../../../base/common/network.js'; import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; @@ -562,7 +561,7 @@ export class ChatService extends Disposable implements IChatService { } async acquireOrLoadSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken, debugOwner?: string): Promise { - if (sessionResource.scheme === Schemas.vscodeLocalChatSession) { + if (LocalChatSessionUri.isLocalSession(sessionResource)) { return this.acquireOrRestoreLocalSession(sessionResource, debugOwner); } else { return this.loadRemoteSession(sessionResource, location, token, debugOwner); @@ -579,7 +578,7 @@ export class ChatService extends Disposable implements IChatService { } } - if (!await this.chatSessionService.canResolveChatSession(sessionResource.scheme)) { + if (!await this.chatSessionService.canResolveChatSession(getChatSessionType(sessionResource))) { return undefined; } @@ -1494,7 +1493,7 @@ export class ChatService extends Disposable implements IChatService { * controls queued-message dequeuing on the server side. */ private _isServerManagedQueue(sessionResource: URI): boolean { - return sessionResource.scheme.startsWith('agent-host-'); + return getChatSessionType(sessionResource).startsWith('agent-host-'); } /** diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 352a53bffda2c..e37726bb9e981 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -13,7 +13,7 @@ import { ChatAgentVoteDirection, ChatCopyKind, IChatSendRequestOptions, IChatUse import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; -import { chatSessionResourceToId } from '../model/chatUri.js'; +import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; type ChatVoteEvent = { direction: 'up' | 'down'; @@ -308,7 +308,7 @@ export class ChatRequestTelemetry { model: this.resolveModelId(this.opts.options?.userSelectedModelId), permissionLevel: this.opts.options?.modeInfo?.kind === ChatModeKind.Ask ? undefined : this.opts.options?.modeInfo?.permissionLevel, chatMode: this.opts.options?.modeInfo?.modeName ?? this.opts.options?.modeInfo?.modeId, - sessionType: this.opts.sessionResource.scheme, + sessionType: getChatSessionType(this.opts.sessionResource), }); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 9e8a7c6125a4e..4a3e636e23538 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -1060,7 +1060,7 @@ suite('ChatService', () => { test('sendRequest on untitled remote session propagates initialSessionOptions to new model', async () => { const remoteScheme = 'remoteProvider'; - const untitledResource = URI.from({ scheme: remoteScheme, path: '/untitled-test-session' }); + const untitledResource = LocalChatSessionUri.getNewContributedSessionUri(remoteScheme); const realResource = URI.from({ scheme: remoteScheme, path: '/real-session-123' }); // Set up the mock chat sessions service diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index a1cde58a3a412..3484089980aa4 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -10,6 +10,7 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionCommitEvent, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap } from '../../common/chatSessionsService.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -150,9 +151,10 @@ export class MockChatSessionsService implements IChatSessionsService { } async getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise { - const provider = this.contentProviders.get(sessionResource.scheme); + const sessionType = getChatSessionType(sessionResource); + const provider = this.contentProviders.get(sessionType); if (!provider) { - throw new Error(`No content provider for ${sessionResource.scheme}`); + throw new Error(`No content provider for ${sessionType}`); } return provider.provideChatSessionContent(sessionResource, token); } From c6c5a002e6dd2ad4b48b04a20549365b73c7e4d8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:58:44 -0700 Subject: [PATCH 02/13] Revert partial changes These belong to a follow up PR --- .../chat/browser/chatSessions/chatSessions.contribution.ts | 1 + .../contrib/chat/test/common/chatService/chatService.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 05df1a613390f..c9ed88f734247 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -52,6 +52,7 @@ import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRang import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 4a3e636e23538..9e8a7c6125a4e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -1060,7 +1060,7 @@ suite('ChatService', () => { test('sendRequest on untitled remote session propagates initialSessionOptions to new model', async () => { const remoteScheme = 'remoteProvider'; - const untitledResource = LocalChatSessionUri.getNewContributedSessionUri(remoteScheme); + const untitledResource = URI.from({ scheme: remoteScheme, path: '/untitled-test-session' }); const realResource = URI.from({ scheme: remoteScheme, path: '/real-session-123' }); // Set up the mock chat sessions service From 9a8fa6565bf1409f7e5ee68c150c6ee96522b544 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:08:16 -0700 Subject: [PATCH 03/13] Enable the toggle markdown preview command for agent diff views too Doesn't work 100% nicely as once toggled back it switch back to the normal inline editor instead of back to the side by side diff view. Will follow up Co-authored-by: Copilot --- extensions/markdown-language-features/package.json | 2 +- .../workbench/browser/parts/editor/editorCommands.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index a9de8b9604150..63617916b3005 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -266,7 +266,7 @@ }, { "command": "markdown.reopenAsPreview", - "when": "activeEditor == workbench.editors.files.textFileEditor && resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", + "when": "(activeEditor == workbench.editors.files.textFileEditor || activeEditor == workbench.editors.textDiffEditor) && resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", "group": "navigation" }, { diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 7c8fdbf44beaa..1e8e77248c10e 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -28,7 +28,7 @@ import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAc import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, EditorPartModalSidebarContext, IsSessionsWindowContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; -import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; +import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isDiffEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../../services/editor/common/editorGroupColumn.js'; @@ -956,7 +956,8 @@ function registerCloseEditorCommands() { for (const { group, editors } of resolvedContext.groupedEditors) { for (const editor of editors) { - const untypedEditor = editor.toUntyped(); + const editorToResolve = isDiffEditorInput(editor) ? editor.modified : editor; + const untypedEditor = editorToResolve.toUntyped(); if (!untypedEditor) { return; // Resolver can only resolve untyped editors } @@ -976,7 +977,7 @@ function registerCloseEditorCommands() { editorReplacementsInGroup.push({ editor: editor, replacement: resolvedEditor.editor, - forceReplaceDirty: editor.resource?.scheme === Schemas.untitled, + forceReplaceDirty: editorToResolve.resource?.scheme === Schemas.untitled, options: resolvedEditor.options }); @@ -998,8 +999,8 @@ function registerCloseEditorCommands() { }; telemetryService.publicLog2('workbenchEditorReopen', { - scheme: editor.resource?.scheme ?? '', - ext: editor.resource ? extname(editor.resource) : '', + scheme: editorToResolve.resource?.scheme ?? '', + ext: editorToResolve.resource ? extname(editorToResolve.resource) : '', from: editor.editorId ?? '', to: resolvedEditor.editor.editorId ?? '' }); From 3a126b58cfd263f5596520ab5c739b63a447cffd Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 1 May 2026 06:57:51 -0700 Subject: [PATCH 04/13] claude sessions endpoint cleanup (#313685) --- .../claudeChatSessionContentProvider.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index d53091a1dd6d3..cc3361535d591 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -10,7 +10,6 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio import { INativeEnvService } from '../../../platform/env/common/envService'; import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; -import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../util/vs/base/common/event'; @@ -144,7 +143,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // and the response footer details — they otherwise both call // `resolveEndpoint` (which hits the cached endpoint list, then // re-filters), which is wasted work and risks divergence. - const endpoint = await this._resolveEndpointForRequest(modelId.toEndpointModelId()); + const endpoint = await this.claudeModels.resolveEndpoint(modelId.toEndpointModelId(), undefined); const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY]; const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined); this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort); @@ -200,19 +199,6 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco }; } - /** - * Resolves a Claude model id to its endpoint. Wraps `resolveEndpoint` in a - * try/catch so transient failures degrade gracefully (return `undefined`) - * instead of breaking the response or session-load path. - */ - private async _resolveEndpointForRequest(modelId: string): Promise { - try { - return await this.claudeModels.resolveEndpoint(modelId, undefined); - } catch { - return undefined; - } - } - /** * Resolves the display string for each unique non-synthetic model id observed in the * session's assistant messages. Returns `undefined` (not an empty map) when no model @@ -240,7 +226,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco if (token.isCancellationRequested) { return; } - const endpoint = await this._resolveEndpointForRequest(modelId); + const endpoint = await this.claudeModels.resolveEndpoint(modelId, undefined); if (endpoint) { detailsByModelId.set(modelId, formatClaudeModelDetails(endpoint)); } From 25c2fe34a7f6dd538347a580d588e68370b3d1dc Mon Sep 17 00:00:00 2001 From: Jah-yee <74031749+Jah-yee@users.noreply.github.com> Date: Fri, 1 May 2026 22:55:34 +0800 Subject: [PATCH 05/13] fix: resolve NoChangeError tool name interpolation and typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use backticks for proper template literal interpolation of ${ToolName.ReadFile}. Fix duplicate 'and and' → 'and'. Import ToolName from registry so message stays in sync if tool name changes. Addresses Copilot AI review feedback: keeps tool name in sync with registry. --- .../copilot/src/extension/tools/node/editFileToolUtils.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index fcac1ac97541a..67d95b8445a26 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -8,6 +8,7 @@ import { realpath } from 'fs/promises'; import { homedir } from 'os'; import * as path from 'path'; import type { LanguageModelChat, PreparedToolInvocation } from 'vscode'; +import { ToolName } from '../common/toolNames'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService'; import { IDiffService } from '../../../platform/diff/common/diffService'; @@ -669,7 +670,7 @@ export async function applyEdit( if (updatedFile === originalFile) { throw new NoChangeError( - 'Original and edited file match exactly. Failed to apply edit. Use the ${ToolName.ReadFile} tool to re-read the file and and determine the correct edit.', + `Original and edited file match exactly. Failed to apply edit. Use the ${ToolName.ReadFile} tool to re-read the file and determine the correct edit.`, filePath ); } From 8c4048e0e9b83bc71dd4e42239a9064ff364a234 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Fri, 1 May 2026 08:23:38 -0700 Subject: [PATCH 06/13] Add Cache Explorer view to chat debug panel (#313620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Cache Explorer view to chat debug panel Add a new "Cache Explorer" entry under "Explore Trace Data" in the chat debug overview. The view helps diagnose prompt-cache misses by diffing two model-turn requests side by side. The pure diff engine (chatDebugCacheDiff.ts) parses the input messages JSON exposed via IChatDebugEventModelTurnContent.sections, normalizes each message to {role, name, text, byteLength}, and produces a per- position signature of the prompt prefix. The first position whose role, length, or content diverges is reported as the cache break — anything after that point cannot be served from the prompt cache. The view (chatDebugCacheExplorerView.ts) lays out a left rail of model turns annotated with cache hit %, A/B summary cards, the prompt signature with the break marker, and a Components accordion that diffs the system prompt and any divergent messages. Sequential pairing is the default (B = current selection, A = previous turn); click in the rail to set B and shift-click to set A. The diff engine ships with 10 unit tests in chatDebugCacheDiff.test.ts. * Cache Explorer iteration: rail groups, OTel-backed metrics, prompt signature bars Iterate on the Cache Explorer view added earlier on this branch: - Left rail groups model turns by parent request and shows the user prompt as the group header. Group rows are collapsible and the full request id is shown in the header. - Each rail row reports agent source, cache hit %, duration, and time for the turn; rows with hit < 90% render the chip in red. - Single-selection model: clicking a row sets it as the current request and the row above is implicitly the previous one to diff against. - Producer plumbing: the file logger now persists copilot_chat.debug_name and gen_ai.response.id alongside the model-turn entry, and the modelTurn content carries a requestId. The summary card surfaces the full network requestId so it can be copied. - Replaced the chip-style prompt signature with a horizontal role- colored bar visualization showing both requests on a shared scale, with a vertical break marker at the divergence index. - Cache performance card replaces the pill row with a structured layout: cache hit headline + token reuse, where the cache broke + estimated lost tokens, and a one-line diff summary. - Component diff and signature lanes use Previous/Current labels instead of A/B. Refs https://github.com/microsoft/vscode/pull/313608 * Cache Explorer: char-level inline diff in Components accordion Replace the plain-text body of each Components row with a side-by-side line + character diff rendered directly into HTML. Uses the existing linesDiffComputers.getDefault().computeDiff() that Monaco's diff editor also uses internally; ignoreTrimWhitespace stays off so cache-relevant whitespace is visible. - Each line is emitted as a div with one of three classes `context`, `add`, `remove` for full-line styling. - Inner range mappings produce char-level highlights inside added or removed lines. - Multi-line inner range mappings are skipped for v1; the surrounding add/remove styling already conveys the change. - Bounded by maxComputationTimeMs=200 so a stray giant tool-result diff cannot stall the renderer. No widget, no editor instance, no layout calls; replaces the existing two raw
bodies with a directly-styled HTML diff. Refs https://github.com/microsoft/vscode/pull/313620 * Cache Explorer: extract text from tool_call_response and tool_call parts The OTel input messages format wraps tool I/O as part-level objects, not as top-level text: - A user/tool message that returns tool output uses { type: 'tool_call_response', id, response: '...' } - An assistant message that invokes a tool uses { type: 'tool_call', id, name, arguments: {...} } Until now parseInputMessages only counted parts with type === 'text', so these messages showed up as zero-byte slots in the diff with both sides labeled '(not present)' \u2014 confusing because tool I/O is the single most cache-relevant content in an agentic loop. This change pulls the response payload out of tool_call_response (and the tool name + arguments out of tool_call) and includes them in the normalized text we diff against. We also reclassify the row's display role to 'tool' when the message is dominated by a tool result so the rail / signature / accordion label it consistently. Two new unit tests pin the extraction behaviour. Refs https://github.com/microsoft/vscode/pull/313620 * Cache Explorer: track request options + likely cache expiration Prompt caches invalidate on more than just message-array changes \u2014 flipping tool_choice, raising reasoning_effort, switching to Claude extended thinking, or changing the response_format all bust the cache even when the prompt prefix is byte-identical. Surface those changes. Producer: - New OTel attribute copilot_chat.request.options carrying a curated subset of the request body. Captures tool_choice, reasoning, reasoning_effort, thinking, thinking_budget, output_config, response_format, text, truncation, context_management, the various penalties, store, stream, stream_options, prediction, seed, parallel_tool_calls, service_tier, metadata, verbosity, snippy, state, intent, intent_threshold, include, plus an 'extra' catch-all for any unrecognised top-level fields. - Persisted onto llm_request entries in the file logger so the data survives session reloads. Consumer plumbing: - New requestOptions?: string on IChatDebugEventModelTurnContent and the matching DTO + ext-host class + proposed API. Read on both the live OTel span path and the on-disk entry path. View: - New 'Request Options' table renders every captured option with Previous and Current columns; rows whose values differ are highlighted with the diff-removed background. The model id is layered on top of the request_options blob so model swaps show up in the same table. - An inline 'Options changed: ...' banner sits below the summary cards so the user spots option drift without scrolling. - Cache performance card now detects the 'likely cache expiration' case: when the model reports 0% hit, the structural diff finds no prefix break, AND the option table is identical, the headline switches to '\u2014 likely cache expiration' with an explanation. When options are the only thing that changed, the break line says so explicitly. Refs https://github.com/microsoft/vscode/pull/313620 * Cache Explorer: address Copilot review nits from #313608 + #313602 Six small follow-ups: - Switch truthy checks to '!== undefined' for token fields in chatDebugFlowGraph.ts (model-turn tooltip) so a turn with 0 input or output tokens still gets a tooltip line. - Same fix for the modelTurn aria label in chatDebugLogsView.ts \u2014 a 0-token turn now still announces 'Model turn: 0 tokens' instead of dropping the count. - Add the cached-tokens row to the modelTurn branch of formatEventDetail in chatDebugEventDetailRenderer.ts (regressed during a recent merge) and add the cachedTokens field to the existing 'modelTurn - with all fields' unit test. - chatDebugFlowGraph tooltip also gains a 'Cached tokens: N' line when present. - Restore the requestName deserialize in ExtHostChatDebug._deserialize Event \u2014 the serializer sends it but the round trip was dropping it. Add the corresponding requestName field to the ChatDebugModelTurnEvent ext-host class so the assignment compiles. * Cache Explorer: address Copilot review on #313620 Five fixes from Copilot's review: - Rename INormalizedMessage.byteLength to charLength (text.length is UTF-16 code units, not bytes), and update all UI labels from 'B' to 'chars' so the displayed unit matches what we actually measure. Touches the diff engine, the explorer view, and the unit tests. - setSession now clears collapsedGroups and resets openComponents to the default expanded set, mirroring how Flow Chart resets its collapse state on session change. Prevents unbounded growth and cross-session collapse-state leaks. - Rail rows are now keyboard accessible: each row is focusable (tabIndex=0), exposes role='button', aria-selected, and aria-label, and responds to Enter/Space. Adds a focus-visible outline. - render() now uses a monotonically-increasing renderToken captured at the start of each call and re-checked after each await; an older render whose model-turn resolves come back late will no longer write into a DOM the newer render has already rebuilt. - _reviveResolvedContent in mainThreadChatDebug now passes through maxInputTokens and maxOutputTokens, which were silently dropped. Refs https://github.com/microsoft/vscode/pull/313620 * Cache Explorer: address Councillor-Opus follow-up nits Five fixes prompted by the council review: - breakBytePos used to fall through to 'cumulative' (the right edge of the bar) when the diff's break index was outside the side's segment list \u2014 it now returns undefined, which the renderer already handles as 'no break marker for this side'. Prevents a logic mismatch between the diff and the segment list from being silently masked as a misleading 'cache broke at the end' marker. - pickCacheRelevantRequestOptions drops the 'extra' catch-all. We now only forward an explicit allowlist of cache-keying body fields to OTel and the on-disk debug log. Keeps any future provider- specific body fields (auth tokens, API keys, personalization) from leaking through; new cache knobs must be added explicitly. - Replace the local JSON-stringify based deepEqual helper in the view with the equals function from vs/base/common/objects, which is already used elsewhere in the workbench for value comparisons. - Add a fast-fail comment to messagesEqual explaining why charLength stays even though it is implied by text equality. - Document the trailing-context loop in renderInlineDiff and the silent selectedIndex clamp on session change so future readers don't think they're bugs. Expand the isLikelyCacheExpiration JSDoc to enumerate other invalidation causes the heuristic cannot distinguish. * Cache Explorer: clarify stableStringify fallback intent Document why stableStringify falls back to String(value) (circular refs / BigInt) and why the diff engine deliberately does not take an ILogService dependency to log such cases. The fallback produces a stable but lossy representation that still surfaces as content drift in the UI, so the failure mode is visible rather than silent. * Cache Explorer: address Copilot review nits round 2 Four small fixes from the latest Copilot review pass: - Update parseInputMessages JSDoc: was still describing charLength as 'byte length' even though the field was renamed. - Update diffPromptSignature comment: it claimed every position from the divergence onward is reported as non-identical, but the algorithm classifies each position independently. The first divergence is what breaks the cache; later identical positions are reported truthfully and the UI keys off the first break index. - Rename the 'bytes' field on the local renderSignature segment type to 'chars' (and the breakBytePos helper to breakCharPos) so the source code matches what the user-visible labels already say. - Drop the dead 'tools' entry from the openComponents Set seed in setSession and the field initializer; the diff pipeline only emits 'system' and 'messages[N]' component names, so the 'tools' entry never matched and had no visible effect. --- .../vscode-node/chatDebugFileLoggerService.ts | 9 + .../extension/prompt/node/chatMLFetcher.ts | 40 + .../vscode-node/otelSpanToChatDebugEvent.ts | 6 +- .../platform/otel/common/genAiAttributes.ts | 2 + .../api/browser/mainThreadChatDebug.ts | 5 + .../workbench/api/common/extHost.protocol.ts | 2 + .../workbench/api/common/extHostChatDebug.ts | 3 + src/vs/workbench/api/common/extHostTypes.ts | 3 + .../browser/chatDebug/chatDebugCacheDiff.ts | 357 +++++ .../chatDebug/chatDebugCacheExplorerView.ts | 1174 +++++++++++++++++ .../chat/browser/chatDebug/chatDebugEditor.ts | 42 +- .../browser/chatDebug/chatDebugFlowGraph.ts | 8 +- .../browser/chatDebug/chatDebugLogsView.ts | 2 +- .../chatDebug/chatDebugOverviewView.ts | 8 + .../chat/browser/chatDebug/chatDebugTypes.ts | 3 +- .../browser/chatDebug/media/chatDebug.css | 610 ++++++++- .../contrib/chat/common/chatDebugService.ts | 2 + .../test/browser/chatDebugCacheDiff.test.ts | 178 +++ .../chatDebugEventDetailRenderer.test.ts | 2 + src/vscode-dts/vscode.proposed.chatDebug.d.ts | 26 + 20 files changed, 2465 insertions(+), 17 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheDiff.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheExplorerView.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatDebugCacheDiff.test.ts diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts index e2e94a2c0d3e0..d868c34e9d2eb 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts @@ -915,6 +915,8 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug const model = asString(span.attributes[GenAiAttr.REQUEST_MODEL]) ?? asString(span.attributes[GenAiAttr.RESPONSE_MODEL]) ?? 'unknown'; + const debugName = asString(span.attributes[CopilotChatAttr.DEBUG_NAME]) + ?? asString(span.attributes[GenAiAttr.AGENT_NAME]); return { ts: span.startTime, dur: duration, @@ -926,6 +928,7 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug status: isError ? 'error' : 'ok', attrs: { model, + ...(debugName ? { debugName } : {}), ...(span.attributes[GenAiAttr.USAGE_INPUT_TOKENS] !== undefined ? { inputTokens: asNumber(span.attributes[GenAiAttr.USAGE_INPUT_TOKENS]) } : {}), @@ -938,6 +941,9 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug ...(span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN] !== undefined ? { ttft: asNumber(span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN]) } : {}), + ...(span.attributes[GenAiAttr.RESPONSE_ID] !== undefined + ? { responseId: asString(span.attributes[GenAiAttr.RESPONSE_ID]) } + : {}), ...(span.attributes[CopilotChatAttr.USER_REQUEST] !== undefined ? { userRequest: String(span.attributes[CopilotChatAttr.USER_REQUEST]) } : {}), @@ -953,6 +959,9 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug ...(span.attributes[GenAiAttr.REQUEST_TOP_P] !== undefined ? { topP: asNumber(span.attributes[GenAiAttr.REQUEST_TOP_P]) } : {}), + ...(span.attributes[CopilotChatAttr.REQUEST_OPTIONS] !== undefined + ? { requestOptions: String(span.attributes[CopilotChatAttr.REQUEST_OPTIONS]) } + : {}), ...(isError && span.status.message ? { error: span.status.message } : {}), }, }; diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 7f70df38ce870..5a6b63d2df1d6 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -304,6 +304,15 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { if (toolDefs) { otelInferenceSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs))); } + // Cache-relevant request options. Anything in this blob, when changed + // between two requests, will invalidate the prompt cache even when + // the messages array is byte-identical. The Cache Explorer uses + // this to surface "options changed" drift alongside the message + // signature diff. + const requestOptions = pickCacheRelevantRequestOptions(requestBody); + if (requestOptions) { + otelInferenceSpan.setAttribute(CopilotChatAttr.REQUEST_OPTIONS, truncateForOTel(JSON.stringify(requestOptions))); + } } tokenCount = await countTokens(); const extensionId = source?.extensionId ?? EXTENSION_ID; @@ -2237,3 +2246,34 @@ export function locationToIntent(location: ChatLocation): string { return 'messages-proxy'; } } + +/** + * Curate the cache-relevant subset of a request body. Anything in the + * returned object that differs between two requests will invalidate the + * prompt cache even when the message array itself is byte-identical + * (e.g. switching from `tool_choice: 'auto'` to `'required'`, raising + * `reasoning_effort`, enabling thinking, changing the response format). + * + * Strict allowlist on purpose: we never want a future provider-specific + * body field (especially anything resembling auth headers, API keys, or + * personal identifiers) to silently leak into the OTel attribute or the + * on-disk debug log via a catch-all. New cache-keying knobs must be + * added explicitly here. + */ +function pickCacheRelevantRequestOptions(body: IEndpointBody): Record | undefined { + const out: Record = {}; + for (const key of [ + 'tool_choice', 'reasoning', 'reasoning_effort', 'thinking', 'thinking_budget', + 'output_config', 'response_format', 'text', 'truncation', 'context_management', + 'frequency_penalty', 'presence_penalty', 'top_logprobs', 'logit_bias', + 'store', 'stream', 'stream_options', 'prediction', 'seed', 'parallel_tool_calls', + 'service_tier', 'metadata', 'verbosity', 'snippy', 'state', 'intent', 'intent_threshold', + 'include', + ] as const) { + const value = (body as Record)[key]; + if (value !== undefined) { + out[key] = value; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts b/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts index 901c6ab3b0896..fadd50e6ec90f 100644 --- a/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts +++ b/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts @@ -479,6 +479,8 @@ function resolveModelTurnContent(span: ICompletedSpanData): vscode.ChatDebugEven content.status = spanStatusToString(span.status.code as SpanStatusCode); content.durationInMillis = span.endTime - span.startTime; content.timeToFirstTokenInMillis = asNumber(span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN]); + content.requestId = asString(span.attributes[GenAiAttr.RESPONSE_ID]); + content.requestOptions = asString(span.attributes[CopilotChatAttr.REQUEST_OPTIONS]); content.maxInputTokens = asNumber(span.attributes[CopilotChatAttr.MAX_PROMPT_TOKENS]); content.maxOutputTokens = asNumber(span.attributes[GenAiAttr.REQUEST_MAX_TOKENS]); content.inputTokens = asNumber(span.attributes[GenAiAttr.USAGE_INPUT_TOKENS]); @@ -676,7 +678,7 @@ function entryToModelTurnEvent(entry: IDebugLogEntry): vscode.ChatDebugModelTurn evt.durationInMillis = entry.dur; evt.timeToFirstTokenInMillis = entry.attrs.ttft as number | undefined; evt.maxOutputTokens = entry.attrs.maxTokens as number | undefined; - evt.requestName = entry.name; + evt.requestName = (entry.attrs.debugName as string | undefined) ?? entry.name; evt.status = entry.status === 'error' ? 'error' : 'success'; return evt; } @@ -807,6 +809,8 @@ async function resolveModelTurnEntry( content.status = entry.status === 'error' ? 'error' : 'success'; content.durationInMillis = entry.dur; content.timeToFirstTokenInMillis = entry.attrs.ttft as number | undefined; + content.requestId = entry.attrs.responseId as string | undefined; + content.requestOptions = entry.attrs.requestOptions as string | undefined; content.maxOutputTokens = entry.attrs.maxTokens as number | undefined; content.inputTokens = entry.attrs.inputTokens as number | undefined; content.outputTokens = entry.attrs.outputTokens as number | undefined; diff --git a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts index c922112966d7c..54acb82775ca7 100644 --- a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts +++ b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts @@ -118,6 +118,8 @@ export const CopilotChatAttr = { REASONING_CONTENT: 'copilot_chat.reasoning_content', /** User's actual typed message text, extracted from prompt context */ USER_REQUEST: 'copilot_chat.user_request', + /** Cache-relevant request options as a JSON blob (tool_choice, reasoning_effort, thinking, response_format, etc.). Used by Cache Explorer. */ + REQUEST_OPTIONS: 'copilot_chat.request.options', /** Resolved context section (code snippets, file contents, etc.) */ PROMPT_CONTEXT: 'copilot_chat.prompt_context', /** Custom instructions section */ diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index c858917a4dbec..5281b3a786f53 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -222,10 +222,15 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb model: dto.model, status: dto.status, durationInMillis: dto.durationInMillis, + timeToFirstTokenInMillis: dto.timeToFirstTokenInMillis, + requestId: dto.requestId, + maxInputTokens: dto.maxInputTokens, + maxOutputTokens: dto.maxOutputTokens, inputTokens: dto.inputTokens, outputTokens: dto.outputTokens, cachedTokens: dto.cachedTokens, totalTokens: dto.totalTokens, + requestOptions: dto.requestOptions, errorMessage: dto.errorMessage, sections: dto.sections, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8d7bbef25c08a..8282d175cde94 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1514,12 +1514,14 @@ export interface IChatDebugEventModelTurnContentDto { readonly status?: string; readonly durationInMillis?: number; readonly timeToFirstTokenInMillis?: number; + readonly requestId?: string; readonly maxInputTokens?: number; readonly maxOutputTokens?: number; readonly inputTokens?: number; readonly outputTokens?: number; readonly cachedTokens?: number; readonly totalTokens?: number; + readonly requestOptions?: string; readonly errorMessage?: string; readonly sections?: readonly IChatDebugMessageSectionDto[]; } diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index a7dbcc72670ff..a32b21525837a 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -284,12 +284,14 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap status: mt.status, durationInMillis: mt.durationInMillis, timeToFirstTokenInMillis: mt.timeToFirstTokenInMillis, + requestId: mt.requestId, maxInputTokens: mt.maxInputTokens, maxOutputTokens: mt.maxOutputTokens, inputTokens: mt.inputTokens, outputTokens: mt.outputTokens, cachedTokens: mt.cachedTokens, totalTokens: mt.totalTokens, + requestOptions: mt.requestOptions, errorMessage: mt.errorMessage, sections: mt.sections?.map(s => ({ name: s.name, content: s.content })), }; @@ -340,6 +342,7 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap evt.sessionResource = sessionResource; evt.parentEventId = dto.parentEventId; evt.model = dto.model; + evt.requestName = dto.requestName; evt.inputTokens = dto.inputTokens; evt.outputTokens = dto.outputTokens; evt.cachedTokens = dto.cachedTokens; diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 34fa2025b7f77..c58430ade8f21 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3634,6 +3634,7 @@ export class ChatDebugModelTurnEvent { created: Date; parentEventId?: string; model?: string; + requestName?: string; inputTokens?: number; outputTokens?: number; cachedTokens?: number; @@ -3772,12 +3773,14 @@ export class ChatDebugEventModelTurnContent { status?: string; durationInMillis?: number; timeToFirstTokenInMillis?: number; + requestId?: string; maxInputTokens?: number; maxOutputTokens?: number; inputTokens?: number; outputTokens?: number; cachedTokens?: number; totalTokens?: number; + requestOptions?: string; errorMessage?: string; sections?: ChatDebugMessageSection[]; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheDiff.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheDiff.ts new file mode 100644 index 0000000000000..2571c02d4c070 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheDiff.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Pure helpers used by the Cache Explorer to compare two model-turn requests + * (A and B) and identify where the prompt prefix diverges. + * + * The engine works on the {@link IChatDebugEventModelTurnContent.sections} + * "Input Messages" section, which is a JSON-stringified array of + * `[{ role, name?, parts: [{ type: 'text', content, name? }, ...] }]` + * matching the OpenTelemetry GenAI semantic convention used by + * `chatParticipantTelemetry.ts`. + * + * All functions are pure — no DOM, no services — so they can be unit tested + * in isolation. + */ + +/** + * A normalized request message used by the diff engine. + */ +export interface INormalizedMessage { + readonly role: string; + readonly name?: string; + /** Concatenation of all `text` parts in the message. */ + readonly text: string; + /** Character length of `text` as a UTF-16 code unit count (`text.length`). */ + readonly charLength: number; +} + +/** Classification of a single signature token when comparing A and B. */ +export const enum CacheDiffKind { + /** Same role+name and same charLength in both A and B. */ + Identical = 'identical', + /** Same role+name and same charLength but different content. */ + ContentDrift = 'contentDrift', + /** Same role+name but different charLength. */ + LengthChange = 'lengthChange', + /** Position exists only in A. */ + OnlyInA = 'onlyInA', + /** Position exists only in B. */ + OnlyInB = 'onlyInB', +} + +/** + * A single token in the side-by-side prompt signature. + * + * The signature is computed by zipping A's and B's normalized messages + * positionally and classifying each index independently. The first + * divergence is what breaks the prompt cache, but later positions can + * still be reported as {@link CacheDiffKind.Identical} if their content + * happens to match \u2014 we surface per-position truth here and let the + * UI decide how to interpret it (the cache-break marker, summary copy, + * and "Where the cache broke" line all key off the *first* divergent + * index, not the last). + */ +export interface ICacheSignatureToken { + readonly index: number; + readonly kind: CacheDiffKind; + readonly aRole?: string; + readonly aName?: string; + readonly aCharLength?: number; + readonly bRole?: string; + readonly bName?: string; + readonly bCharLength?: number; +} + +/** + * The first place where A and B's prompt prefix diverges. Anything after + * this index cannot be served from the prompt cache. + */ +export interface ICacheBreak { + readonly index: number; + readonly kind: Exclude; +} + +/** + * A single drifting component (e.g. a message at index N). + */ +export interface IComponentDrift { + readonly name: string; + readonly role?: string; + readonly status: CacheDiffKind; + readonly aSize: number; + readonly bSize: number; +} + +/** + * Aggregate result of comparing two requests. + */ +export interface ICacheDiffResult { + readonly signature: readonly ICacheSignatureToken[]; + readonly break: ICacheBreak | undefined; + readonly drift: readonly IComponentDrift[]; + /** + * Counts of identical / drift / one-sided positions across the whole + * signature. Useful for the summary pills. + */ + readonly counts: { + readonly identical: number; + readonly contentDrift: number; + readonly lengthChange: number; + readonly onlyInA: number; + readonly onlyInB: number; + }; +} + +interface IRawPart { + readonly type?: string; + readonly content?: unknown; + readonly name?: string; + readonly id?: string; + readonly arguments?: unknown; + readonly response?: unknown; +} + +interface IRawMessage { + readonly role?: string; + readonly name?: string; + readonly parts?: readonly IRawPart[]; +} + +/** + * Parse a JSON-encoded `inputMessages` payload into normalized messages. + * + * Returns an empty array on any parse error so callers can render a clear + * empty-state without try/catch boilerplate. + */ +export function parseInputMessages(inputMessagesJson: string | undefined): readonly INormalizedMessage[] { + if (!inputMessagesJson) { + return []; + } + let raw: unknown; + try { + raw = JSON.parse(inputMessagesJson); + } catch { + return []; + } + if (!Array.isArray(raw)) { + return []; + } + + const out: INormalizedMessage[] = []; + for (const m of raw as readonly IRawMessage[]) { + if (!m || typeof m !== 'object') { + continue; + } + let role = typeof m.role === 'string' ? m.role : 'unknown'; + const name = typeof m.name === 'string' ? m.name : undefined; + let text = ''; + let hasToolResponse = false; + let hasToolCall = false; + let hasText = false; + if (Array.isArray(m.parts)) { + for (const p of m.parts) { + if (!p || typeof p !== 'object') { + continue; + } + switch (p.type) { + case undefined: + case 'text': + case 'reasoning': + if (typeof p.content === 'string') { + text += p.content; + hasText = true; + } + break; + case 'tool_call_response': + case 'tool_result': + if (typeof p.response === 'string') { + text += p.response; + } else if (p.response !== undefined) { + text += stableStringify(p.response); + } else if (typeof p.content === 'string') { + text += p.content; + } else if (p.content !== undefined) { + text += stableStringify(p.content); + } + hasToolResponse = true; + break; + case 'tool_call': + // Tool calls live on assistant messages; include their + // stringified arguments so a tool-call argument change + // (e.g. file path) shows up as drift. + if (p.name) { text += `call:${p.name}`; } + if (p.arguments !== undefined) { text += stableStringify(p.arguments); } + hasToolCall = true; + break; + } + } + } + // If a message is dominated by tool I/O, label its role accordingly + // so the visualization labels it as `tool` rather than as a `user` + // or `assistant` message with mysterious empty content. + if (hasToolResponse && !hasText) { + role = 'tool'; + } else if (hasToolCall && !hasText && role === 'assistant') { + role = 'assistant'; + } + out.push({ role, name, text, charLength: text.length }); + } + return out; +} + +/** + * Render an opaque value (tool arguments, response payload) as a string in + * a way that matches what an HTTP client would actually serialize. We do + * not normalize key order: if a provider's serializer differs between + * requests, that *is* a real cache break we want to surface. + * + * The fallback to {@link String} is reached only for values that + * `JSON.stringify` rejects \u2014 circular references or `BigInt` payloads. + * Both produce a stable but lossy representation (e.g. `[object Object]`) + * which still surfaces as content drift in the diff rather than silently + * matching, so the user notices that something unusual went through. We + * intentionally do not log here so the diff engine stays free of service + * dependencies; the caller is welcome to wrap with logging when needed. + */ +function stableStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Returns true iff the two messages have the same role, name, and content. + * + * The `charLength` check is redundant with `text` equality but acts as a + * cheap fast-fail: comparing two large message bodies that already differ + * in length is wasted work. + */ +function messagesEqual(a: INormalizedMessage, b: INormalizedMessage): boolean { + return a.role === b.role && a.name === b.name && a.charLength === b.charLength && a.text === b.text; +} + +/** + * Compute the per-position diff between two normalized message arrays. + * + * The algorithm is intentionally simple (positional zip) rather than a full + * Myers diff: prompt caches are prefix-based, so the moment two messages at + * the same index diverge in role, length, or content the cache breaks. + * Reporting that first divergence is far more useful than computing a + * minimum edit script. + */ +export function diffPromptSignature(a: readonly INormalizedMessage[], b: readonly INormalizedMessage[]): ICacheDiffResult { + const signature: ICacheSignatureToken[] = []; + const drift: IComponentDrift[] = []; + const counts = { identical: 0, contentDrift: 0, lengthChange: 0, onlyInA: 0, onlyInB: 0 }; + let breakResult: ICacheBreak | undefined; + let broken = false; + + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i++) { + const ai = a[i]; + const bi = b[i]; + + if (ai && !bi) { + counts.onlyInA++; + signature.push({ index: i, kind: CacheDiffKind.OnlyInA, aRole: ai.role, aName: ai.name, aCharLength: ai.charLength }); + drift.push({ name: `messages[${i}]`, role: ai.role, status: CacheDiffKind.OnlyInA, aSize: ai.charLength, bSize: 0 }); + if (!broken) { + broken = true; + breakResult = { index: i, kind: CacheDiffKind.OnlyInA }; + } + continue; + } + if (bi && !ai) { + counts.onlyInB++; + signature.push({ index: i, kind: CacheDiffKind.OnlyInB, bRole: bi.role, bName: bi.name, bCharLength: bi.charLength }); + drift.push({ name: `messages[${i}]`, role: bi.role, status: CacheDiffKind.OnlyInB, aSize: 0, bSize: bi.charLength }); + if (!broken) { + broken = true; + breakResult = { index: i, kind: CacheDiffKind.OnlyInB }; + } + continue; + } + // Both present + if (!ai || !bi) { + continue; // unreachable, but appeases strict null checks + } + if (messagesEqual(ai, bi)) { + counts.identical++; + signature.push({ + index: i, kind: CacheDiffKind.Identical, + aRole: ai.role, aName: ai.name, aCharLength: ai.charLength, + bRole: bi.role, bName: bi.name, bCharLength: bi.charLength, + }); + continue; + } + // Diverged + const kind = ai.charLength === bi.charLength ? CacheDiffKind.ContentDrift : CacheDiffKind.LengthChange; + if (kind === CacheDiffKind.ContentDrift) { + counts.contentDrift++; + } else { + counts.lengthChange++; + } + signature.push({ + index: i, kind, + aRole: ai.role, aName: ai.name, aCharLength: ai.charLength, + bRole: bi.role, bName: bi.name, bCharLength: bi.charLength, + }); + drift.push({ name: `messages[${i}]`, role: ai.role, status: kind, aSize: ai.charLength, bSize: bi.charLength }); + if (!broken) { + broken = true; + breakResult = { index: i, kind }; + } + } + + return { signature, break: breakResult, drift, counts }; +} + +/** + * Add a leading "system" drift entry to the report when the system + * instructions differ between the two requests. + */ +export function appendSystemDrift( + drift: IComponentDrift[], + aSystem: string | undefined, + bSystem: string | undefined, +): IComponentDrift[] { + if (aSystem === bSystem) { + return drift; + } + const aSize = aSystem?.length ?? 0; + const bSize = bSystem?.length ?? 0; + let status: CacheDiffKind; + if (!aSystem) { + status = CacheDiffKind.OnlyInB; + } else if (!bSystem) { + status = CacheDiffKind.OnlyInA; + } else { + status = aSize === bSize ? CacheDiffKind.ContentDrift : CacheDiffKind.LengthChange; + } + return [{ name: 'system', status, aSize, bSize }, ...drift]; +} + +/** + * Format a normalized message into a single-line `role[-name]:bytes` token, + * matching the convention used by the existing `promptTypes` telemetry. + */ +export function formatSignatureToken(token: ICacheSignatureToken): string { + const role = token.bRole ?? token.aRole ?? 'unknown'; + const name = token.bName ?? token.aName; + const a = token.aCharLength; + const b = token.bCharLength; + const sizeText = a !== undefined && b !== undefined && a !== b + ? `${a}\u2192${b}` + : a !== undefined && b === undefined + ? `${a}\u21920` + : a === undefined && b !== undefined + ? `0\u2192${b}` + : `${b ?? a ?? 0}`; + return name ? `${role}-${name}:${sizeText}` : `${role}:${sizeText}`; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheExplorerView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheExplorerView.ts new file mode 100644 index 0000000000000..bd566ba904a43 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheExplorerView.ts @@ -0,0 +1,1174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Orientation, Sash, SashState } from '../../../../../base/browser/ui/sash/sash.js'; +import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { safeIntl } from '../../../../../base/common/date.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { defaultBreadcrumbsWidgetStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; +import { RangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; +import { IChatDebugEventModelTurnContent, IChatDebugMessageSection, IChatDebugModelTurnEvent, IChatDebugService, IChatDebugUserMessageEvent } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { appendSystemDrift, CacheDiffKind, diffPromptSignature, ICacheDiffResult, IComponentDrift, INormalizedMessage, parseInputMessages } from './chatDebugCacheDiff.js'; +import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; + +const $ = DOM.$; +const numberFormatter = safeIntl.NumberFormat(); +const timeFormatter = safeIntl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', second: '2-digit' }); + +/** Default rail width in pixels. */ +const RAIL_DEFAULT_WIDTH = 280; +const RAIL_MIN_WIDTH = 180; +const RAIL_MAX_WIDTH = 600; + +/** + * Navigation events fired by the Cache Explorer breadcrumb. + */ +export const enum CacheExplorerNavigation { + Home = 'home', + Overview = 'overview', +} + +/** Resolved data for one A or B side. */ +interface ISideData { + readonly event: IChatDebugModelTurnEvent; + readonly content: IChatDebugEventModelTurnContent | undefined; + readonly system: string | undefined; + readonly inputMessages: readonly INormalizedMessage[]; +} + +/** A grouping of model turns sharing the same parent (one user request). */ +interface ITurnGroup { + readonly key: string; + readonly userMessage: IChatDebugUserMessageEvent | undefined; + readonly turns: readonly { readonly turn: IChatDebugModelTurnEvent; readonly index: number }[]; +} + +/** + * Cache Explorer view — the third entry under "Explore Trace Data". Shows a + * left rail of model turns with their cache hit %, plus a side-by-side prompt + * signature diff that pinpoints where the prefix breaks. + * + * v1 reads {@link IChatDebugEventModelTurnContent} from the in-memory chat + * debug service via {@link IChatDebugService.resolveEvent}. Content may be + * truncated by the OTel attribute cap; the file-logger backed full-fidelity + * provider is a follow-up. + */ +export class ChatDebugCacheExplorerView extends Disposable { + + private readonly _onNavigate = this._register(new Emitter()); + readonly onNavigate = this._onNavigate.event; + + readonly container: HTMLElement; + private readonly breadcrumbWidget: BreadcrumbsWidget; + private readonly rail: HTMLElement; + private readonly railList: HTMLElement; + private readonly content: HTMLElement; + private readonly sash: Sash; + private railWidth = RAIL_DEFAULT_WIDTH; + private readonly loadDisposables = this._register(new DisposableStore()); + private readonly refreshScheduler: RunOnceScheduler; + + private currentSessionResource: URI | undefined; + private modelTurns: IChatDebugModelTurnEvent[] = []; + /** Selected turn (B side). A is computed as `selectedIndex - 1`. -1 = no explicit selection yet. */ + private selectedIndex = -1; + + /** + * Monotonically-increasing render token. Each call to {@link render} + * captures the current value, then re-checks it after each await; if a + * newer render has started in the meantime, the older one bails out + * before mutating the DOM. Avoids races where a slow model-turn + * resolve from one session writes into another's panel. + */ + private renderToken = 0; + + /** Cache of resolved model-turn content keyed by event id. */ + private readonly resolvedCache = new Map(); + + /** Components currently expanded (by component name). */ + private readonly openComponents = new Set(['system']); + + /** Rail groups currently collapsed (by group key — the parent event id). */ + private readonly collapsedGroups = new Set(); + + constructor( + parent: HTMLElement, + @IChatService private readonly chatService: IChatService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + ) { + super(); + this.container = DOM.append(parent, $('.chat-debug-cache')); + DOM.hide(this.container); + + // Breadcrumb + const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb')); + this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles)); + this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget)); + this._register(this.breadcrumbWidget.onDidSelectItem(e => { + if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) { + this.breadcrumbWidget.setSelection(undefined); + const items = this.breadcrumbWidget.getItems(); + const idx = items.indexOf(e.item); + if (idx === 0) { + this._onNavigate.fire(CacheExplorerNavigation.Home); + } else if (idx === 1) { + this._onNavigate.fire(CacheExplorerNavigation.Overview); + } + } + })); + + // Body: 2-column split with resizable rail + const body = DOM.append(this.container, $('.chat-debug-cache-body')); + this.rail = DOM.append(body, $('.chat-debug-cache-rail')); + this.rail.style.width = `${this.railWidth}px`; + this.railList = DOM.append(this.rail, $('.chat-debug-cache-rail-list')); + this.content = DOM.append(body, $('.chat-debug-cache-content')); + + this.sash = this._register(new Sash(body, { + getVerticalSashLeft: () => this.railWidth, + }, { orientation: Orientation.VERTICAL })); + this.sash.state = SashState.Enabled; + let sashStartWidth: number | undefined; + this._register(this.sash.onDidStart(() => sashStartWidth = this.railWidth)); + this._register(this.sash.onDidEnd(() => { + sashStartWidth = undefined; + this.sash.layout(); + })); + this._register(this.sash.onDidChange(e => { + if (sashStartWidth === undefined) { + return; + } + const delta = e.currentX - e.startX; + const next = Math.max(RAIL_MIN_WIDTH, Math.min(RAIL_MAX_WIDTH, sashStartWidth + delta)); + this.railWidth = next; + this.rail.style.width = `${next}px`; + this.sash.layout(); + })); + + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.render(), 50)); + } + + setSession(sessionResource: URI): void { + if (!this.currentSessionResource || this.currentSessionResource.toString() !== sessionResource.toString()) { + this.resolvedCache.clear(); + this.collapsedGroups.clear(); + this.openComponents.clear(); + this.openComponents.add('system'); + this.selectedIndex = -1; + } + this.currentSessionResource = sessionResource; + } + + show(): void { + DOM.show(this.container); + this.render(); + } + + hide(): void { + DOM.hide(this.container); + this.refreshScheduler.cancel(); + } + + refresh(): void { + if (this.container.style.display !== 'none' && !this.refreshScheduler.isScheduled()) { + this.refreshScheduler.schedule(); + } + } + + updateBreadcrumb(): void { + if (!this.currentSessionResource) { + return; + } + const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); + this.breadcrumbWidget.setItems([ + new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Logs"), true), + new TextBreadcrumbItem(sessionTitle, true), + new TextBreadcrumbItem(localize('chatDebug.cacheExplorer', "Cache Explorer")), + ]); + } + + private async render(): Promise { + // Monotonically-increasing token. Captured at the start of every + // render() and re-checked after each await so an in-flight resolve + // that's been superseded by a newer render bails out before + // touching the DOM. + const token = ++this.renderToken; + const isCurrent = () => token === this.renderToken; + + this.updateBreadcrumb(); + this.loadDisposables.clear(); + DOM.clearNode(this.railList); + DOM.clearNode(this.content); + + if (!this.currentSessionResource) { + return; + } + + const events = this.chatDebugService.getEvents(this.currentSessionResource); + this.modelTurns = events.filter((e): e is IChatDebugModelTurnEvent => e.kind === 'modelTurn'); + const userMessages = events.filter((e): e is IChatDebugUserMessageEvent => e.kind === 'userMessage'); + + if (this.modelTurns.length === 0) { + const empty = DOM.append(this.content, $('.chat-debug-cache-empty')); + empty.textContent = localize('chatDebug.cache.noTurns', "No model turns recorded for this session yet."); + return; + } + + // Default to the most recent turn on first display, and silently + // fall back to the most recent turn when switching to a session + // that has fewer turns than the previous selection \u2014 the rail + // re-renders so the new selection is still visible. + if (this.selectedIndex < 0 || this.selectedIndex >= this.modelTurns.length) { + this.selectedIndex = this.modelTurns.length - 1; + } + + this.renderRail(buildTurnGroups(this.modelTurns, userMessages)); + this.renderTitleRow(); + + const bEvent = this.modelTurns[this.selectedIndex]; + const aEvent = this.selectedIndex > 0 ? this.modelTurns[this.selectedIndex - 1] : undefined; + + if (!aEvent) { + // No prior turn to diff against — still surface OTel-reported cache hit + // and request metadata for the first turn of a session. + const b = await this.resolveSide(bEvent); + if (!isCurrent()) { + return; + } + this.renderSingleSummary(b); + return; + } + + const [a, b] = await Promise.all([this.resolveSide(aEvent), this.resolveSide(bEvent)]); + // If a newer render started while we were resolving, drop this one. + if (!isCurrent()) { + return; + } + + const diff = diffPromptSignature(a.inputMessages, b.inputMessages); + const drift = appendSystemDrift([...diff.drift], a.system, b.system); + + this.renderSummary(a, b, diff); + this.renderSignature(a, b, diff); + this.renderRequestOptions(a, b); + this.renderComponents(drift, a, b); + } + + private async resolveSide(event: IChatDebugModelTurnEvent): Promise { + let content: IChatDebugEventModelTurnContent | undefined; + if (event.id) { + if (this.resolvedCache.has(event.id)) { + content = this.resolvedCache.get(event.id); + } else { + const r = await this.chatDebugService.resolveEvent(event.id); + content = r && r.kind === 'modelTurn' ? r : undefined; + this.resolvedCache.set(event.id, content); + } + } + const system = findSection(content?.sections, 'System'); + const inputMessagesJson = findSection(content?.sections, 'Input Messages'); + const inputMessages = parseInputMessages(inputMessagesJson); + return { event, content, system, inputMessages }; + } + + private renderRail(groups: readonly ITurnGroup[]): void { + for (const group of groups) { + const collapsed = this.collapsedGroups.has(group.key); + const header = DOM.append(this.railList, $('.chat-debug-cache-group-header')); + if (collapsed) { + header.classList.add('is-collapsed'); + } + header.tabIndex = 0; + header.setAttribute('role', 'button'); + header.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + header.title = localize('chatDebug.cache.toggleGroup', "Toggle group"); + + const topLine = DOM.append(header, $('.chat-debug-cache-group-top')); + DOM.append(topLine, $('span.chat-debug-cache-group-chev')); + const headerLine = DOM.append(topLine, $('.chat-debug-cache-group-prompt')); + headerLine.textContent = group.userMessage?.message?.trim() || localize('chatDebug.cache.unknownPrompt', "(no prompt captured)"); + const countBadge = DOM.append(topLine, $('span.chat-debug-cache-group-count')); + countBadge.textContent = String(group.turns.length); + + const headerMeta = DOM.append(header, $('.chat-debug-cache-group-meta')); + headerMeta.textContent = group.key; + headerMeta.title = localize('chatDebug.cache.requestIdTooltip', "Request id: {0}", group.key); + + const toggle = () => { + if (this.collapsedGroups.has(group.key)) { + this.collapsedGroups.delete(group.key); + } else { + this.collapsedGroups.add(group.key); + } + this.refresh(); + }; + this.loadDisposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, toggle)); + this.loadDisposables.add(DOM.addDisposableListener(header, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + })); + + if (collapsed) { + continue; + } + + for (const { turn: evt, index: i } of group.turns) { + const row = DOM.append(this.railList, $('.chat-debug-cache-turn')); + if (i === this.selectedIndex) { row.classList.add('is-selected'); } + const idx = DOM.append(row, $('.chat-debug-cache-turn-idx')); + idx.textContent = String(i).padStart(2, ' '); + + const main = DOM.append(row, $('.chat-debug-cache-turn-main')); + + // Top line: agent source with bracketed cache hit, duration, and timestamp + const top = DOM.append(main, $('.chat-debug-cache-turn-top')); + const source = DOM.append(top, $('span.chat-debug-cache-turn-source')); + source.textContent = evt.requestName || localize('chatDebug.cache.modelTurn', "Model Turn"); + if (evt.cachedTokens !== undefined && evt.inputTokens) { + const hit = computeCacheHit(evt); + const hitChip = DOM.append(top, $('span.chat-debug-cache-turn-chip.chat-debug-cache-turn-hit', undefined, + localize('chatDebug.cache.hitChip', "[cache {0}%]", formatCachePctInt(hit)))); + if (hit < 90) { + hitChip.classList.add('is-bad'); + } + } + if (evt.durationInMillis !== undefined) { + DOM.append(top, $('span.chat-debug-cache-turn-chip', undefined, localize('chatDebug.cache.msChip', "[{0}ms]", numberFormatter.value.format(Math.round(evt.durationInMillis))))); + } + DOM.append(top, $('span.chat-debug-cache-turn-chip', undefined, `[${timeFormatter.value.format(evt.created)}]`)); + + // Bottom line: model name + if (evt.model) { + const sub = DOM.append(main, $('.chat-debug-cache-turn-sub')); + sub.textContent = evt.model; + } + + row.title = localize('chatDebug.cache.turnHelp', "Click to compare this request against the previous one"); + row.tabIndex = 0; + row.setAttribute('role', 'button'); + row.setAttribute('aria-selected', i === this.selectedIndex ? 'true' : 'false'); + row.setAttribute('aria-label', localize('chatDebug.cache.turnAria', "Turn {0}: {1}", i, evt.requestName ?? evt.model ?? localize('chatDebug.cache.modelTurn', "Model Turn"))); + const select = () => { + if (this.selectedIndex !== i) { + this.selectedIndex = i; + this.refresh(); + } + }; + this.loadDisposables.add(DOM.addDisposableListener(row, DOM.EventType.CLICK, select)); + this.loadDisposables.add(DOM.addDisposableListener(row, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + select(); + } + })); + } + } + } + + private renderTitleRow(): void { + const titleRow = DOM.append(this.content, $('.chat-debug-cache-title-row')); + const title = DOM.append(titleRow, $('h2.chat-debug-cache-title')); + title.textContent = localize('chatDebug.cacheExplorer.title', "Cache Explorer — Prefix Diff"); + } + + private renderSummary(a: ISideData, b: ISideData, diff: ICacheDiffResult): void { + const row = DOM.append(this.content, $('.chat-debug-cache-summary')); + row.appendChild(this.renderSideCard(a, localize('chatDebug.cache.previousRequest', "Previous request"))); + row.appendChild(this.renderSideCard(b, localize('chatDebug.cache.requestTitle', "Request"))); + + const breakCard = DOM.append(row, $('.chat-debug-cache-card.break')); + DOM.append(breakCard, $('.chat-debug-cache-card-h', undefined, localize('chatDebug.cache.performance', "Cache performance"))); + + // Section 1: cache hit headline + absolute counts + const hit = computeCacheHit(b.event); + const inputTokens = b.event.inputTokens ?? 0; + const cachedTokens = b.event.cachedTokens ?? 0; + const lostTokens = Math.max(0, inputTokens - cachedTokens); + const optionsDiff = computeOptionsDiff(a, b); + const expiration = isLikelyCacheExpiration(hit, diff, optionsDiff); + + const headline = DOM.append(breakCard, $('.chat-debug-cache-card-headline')); + if (expiration) { + headline.textContent = localize('chatDebug.cache.expirationHeadline', + "{0}% cache hit \u2014 likely cache expiration", + formatCachePct(hit), + ); + } else { + headline.textContent = localize('chatDebug.cache.hitHeadline', "{0}% cache hit", formatCachePct(hit)); + } + const counts = DOM.append(breakCard, $('.chat-debug-cache-card-sub')); + counts.textContent = localize('chatDebug.cache.tokensReused', + "{0} of {1} input tokens reused", + numberFormatter.value.format(cachedTokens), + numberFormatter.value.format(inputTokens), + ); + + // Section 2: where the cache broke + DOM.append(breakCard, $('.chat-debug-cache-perf-rule')); + DOM.append(breakCard, $('.chat-debug-cache-perf-section-h', undefined, localize('chatDebug.cache.whereBroke', "Where the cache broke"))); + const breakLine = DOM.append(breakCard, $('.chat-debug-cache-perf-line')); + if (expiration) { + breakLine.textContent = localize('chatDebug.cache.expirationNote', + "The prompt prefix matches but the model still treated this as a fresh request. Most likely the cached entry expired between requests.", + ); + } else if (diff.break) { + const componentName = diff.break.index === 0 + ? localize('chatDebug.cache.firstMessage', "the first message") + : `messages[${diff.break.index}]`; + breakLine.textContent = localize('chatDebug.cache.breakAt', + "At {0} \u2014 {1}", + componentName, + describeBreakKind(diff.break.kind, diff, b), + ); + if (lostTokens > 0 && inputTokens > 0) { + const lostPct = (lostTokens / inputTokens) * 100; + const lossLine = DOM.append(breakCard, $('.chat-debug-cache-perf-line')); + lossLine.textContent = localize('chatDebug.cache.lossLine', + "Lost: {0} tokens ({1}% of this request)", + numberFormatter.value.format(lostTokens), + formatCachePct(lostPct), + ); + } + } else if (optionsDiff.length > 0) { + breakLine.textContent = localize('chatDebug.cache.optionsBroke', + "Request options changed \u2014 the cache was invalidated even though the message prefix matches.", + ); + } else { + breakLine.textContent = localize('chatDebug.cache.noBreak', "No prefix divergence detected."); + } + + // Section 3: structural diff summary + DOM.append(breakCard, $('.chat-debug-cache-perf-rule')); + DOM.append(breakCard, $('.chat-debug-cache-perf-section-h', undefined, localize('chatDebug.cache.diffSummary', "Diff summary"))); + const summaryLine = DOM.append(breakCard, $('.chat-debug-cache-perf-line')); + const inPlaceChanged = diff.counts.contentDrift + diff.counts.lengthChange; + const addedInB = diff.counts.onlyInB; + const droppedFromA = diff.counts.onlyInA; + const parts: string[] = [ + localize('chatDebug.cache.summaryIdentical', "{0} identical", diff.counts.identical), + localize('chatDebug.cache.summaryChanged', "{0} in-place changed", inPlaceChanged), + ]; + if (addedInB > 0) { + parts.push(localize('chatDebug.cache.summaryAdded', "{0} added in this request", addedInB)); + } + if (droppedFromA > 0) { + parts.push(localize('chatDebug.cache.summaryDropped', "{0} dropped from previous", droppedFromA)); + } + summaryLine.textContent = parts.join(' \u00b7 '); + + // Inline one-liner: surface request-option drift right under the + // summary cards so it is visible regardless of which card the user + // scans first. The detailed Request options card lives in the + // Components row. + if (optionsDiff.length > 0) { + const optsLine = DOM.append(this.content, $('.chat-debug-cache-options-banner')); + optsLine.textContent = localize('chatDebug.cache.optionsBanner', + "Options changed: {0}", + optionsDiff.map(d => `${d.key} (${formatOptionValue(d.previous)} \u2192 ${formatOptionValue(d.current)})`).join(', '), + ); + } + } + + private renderSideCard(data: ISideData, title?: string): HTMLElement { + const card = $('.chat-debug-cache-card'); + if (title) { + DOM.append(card, $('.chat-debug-cache-card-h', undefined, title)); + } + this.appendKv(card, localize('chatDebug.cache.model', "model"), data.event.model ?? '\u2014'); + this.appendKv(card, localize('chatDebug.cache.inputTok', "input tok"), formatTokens(data.event.inputTokens)); + this.appendKv(card, localize('chatDebug.cache.cachedTok', "cached tok"), formatTokens(data.event.cachedTokens)); + this.appendKv(card, localize('chatDebug.cache.cacheHit', "cache hit"), `${formatCachePct(computeCacheHit(data.event))}%`); + + const startTime = data.event.created; + const endTime = data.event.durationInMillis !== undefined + ? new Date(startTime.getTime() + data.event.durationInMillis) + : undefined; + this.appendKv(card, localize('chatDebug.cache.startTime', "startTime"), startTime.toISOString(), true); + if (endTime) { + this.appendKv(card, localize('chatDebug.cache.endTime', "endTime"), endTime.toISOString(), true); + } + if (data.event.durationInMillis !== undefined) { + this.appendKv(card, localize('chatDebug.cache.duration', "duration"), `${numberFormatter.value.format(Math.round(data.event.durationInMillis))}ms`); + } + const ttft = data.content?.timeToFirstTokenInMillis; + if (ttft !== undefined) { + this.appendKv(card, localize('chatDebug.cache.ttft', "timeToFirstToken"), `${numberFormatter.value.format(Math.round(ttft))}ms`); + } + const requestId = data.content?.requestId ?? data.event.parentEventId ?? data.event.id; + if (requestId) { + this.appendKv(card, localize('chatDebug.cache.requestId', "requestId"), requestId, true); + } + return card; + } + + /** + * Render the summary cards alone when there is no prior turn to diff + * against (e.g. the first request in a brand-new session). The OTel- + * reported cache hit is still useful here — the system prompt and tool + * definitions can already be cached from previous sessions. + */ + private renderSingleSummary(b: ISideData): void { + const row = DOM.append(this.content, $('.chat-debug-cache-summary')); + row.appendChild(this.renderSideCard(b, localize('chatDebug.cache.requestTitle', "Request"))); + + const note = DOM.append(row, $('.chat-debug-cache-card.break')); + DOM.append(note, $('.chat-debug-cache-card-h', undefined, localize('chatDebug.cache.firstRequest', "First request in session"))); + const headline = DOM.append(note, $('.chat-debug-cache-card-headline')); + headline.textContent = `${formatCachePct(computeCacheHit(b.event))}%`; + const sub = DOM.append(note, $('.chat-debug-cache-card-sub')); + sub.textContent = localize('chatDebug.cache.firstRequestNote', "OTel-reported cache hit. Nothing earlier in this session to diff against \u2014 the system prompt and tools may still match a previous session's cache."); + } + + private appendKv(parent: HTMLElement, key: string, value: string, copyable: boolean = false): void { + const row = DOM.append(parent, $('.chat-debug-cache-kv')); + DOM.append(row, $('span.k', undefined, key)); + const valueEl = DOM.append(row, $('span.v', undefined, value)); + if (copyable) { + valueEl.classList.add('chat-debug-cache-request-id'); + valueEl.title = value; + } + } + + private renderSignature(a: ISideData, b: ISideData, diff: ICacheDiffResult): void { + const section = DOM.append(this.content, $('.chat-debug-cache-section')); + const heading = DOM.append(section, $('h3.chat-debug-cache-section-h')); + heading.textContent = localize('chatDebug.cache.signatureHeading', "Prompt Signature"); + + const legend = DOM.append(section, $('.chat-debug-cache-sig-legend')); + for (const role of ['system', 'user', 'assistant', 'tool']) { + const entry = DOM.append(legend, $('span.chat-debug-cache-sig-legend-entry')); + DOM.append(entry, $(`span.chat-debug-cache-sig-swatch.role-${role}`)); + DOM.append(entry, DOM.$('span', undefined, role)); + } + const driftEntry = DOM.append(legend, $('span.chat-debug-cache-sig-legend-entry')); + DOM.append(driftEntry, $('span.chat-debug-cache-sig-swatch.role-drift')); + DOM.append(driftEntry, DOM.$('span', undefined, localize('chatDebug.cache.driftLegend', "drift"))); + + // Per-side char-length sequences. We prepend a synthetic 'system' segment for + // the system prompt so it shows up in the bar even though it's not in + // the inputMessages array. + interface ISegment { + readonly role: string; + readonly chars: number; + readonly drift: boolean; + readonly label: string; + } + const toSegments = (side: ISideData, isA: boolean): ISegment[] => { + const segs: ISegment[] = []; + const sys = side.system; + if (sys) { + const other = isA ? b.system : a.system; + segs.push({ role: 'system', chars: sys.length, drift: sys !== (other ?? ''), label: 'system' }); + } + side.inputMessages.forEach((m, i) => { + const tok = diff.signature[i]; + const kind = tok?.kind; + const drift = kind === CacheDiffKind.ContentDrift + || kind === CacheDiffKind.LengthChange + || (isA && kind === CacheDiffKind.OnlyInA) + || (!isA && kind === CacheDiffKind.OnlyInB); + segs.push({ role: m.role, chars: m.charLength, drift, label: m.name ? `${m.role}-${m.name}` : m.role }); + }); + return segs; + }; + + const aSegs = toSegments(a, true); + const bSegs = toSegments(b, false); + const totalA = aSegs.reduce((s, x) => s + x.chars, 0); + const totalB = bSegs.reduce((s, x) => s + x.chars, 0); + const max = Math.max(totalA, totalB, 1); + + // Compute char position of cache break inside each side's bar. + // Returns undefined if the break index falls outside the side's + // segment list (e.g. break is at messages[N] but B has fewer + // messages); rendering that as the right edge of the bar would + // misleadingly suggest "the cache broke at the end". + const breakCharPos = (segs: readonly ISegment[]): number | undefined => { + if (!diff.break) { + return undefined; + } + // Skip the synthetic system segment when matching diff.break.index. + let cumulative = 0; + let skipSystem = segs[0]?.role === 'system'; + let idx = 0; + for (const s of segs) { + if (skipSystem) { + cumulative += s.chars; + skipSystem = false; + continue; + } + if (idx === diff.break.index) { + return cumulative; + } + cumulative += s.chars; + idx++; + } + return undefined; + }; + + const buildLane = (label: string, segs: readonly ISegment[], breakPos: number | undefined): HTMLElement => { + const row = $('.chat-debug-cache-sig-lane-row'); + DOM.append(row, $('.chat-debug-cache-sig-lane-label', undefined, label)); + const bar = DOM.append(row, $('.chat-debug-cache-sig-bar')); + let sideTotal = 0; + for (const s of segs) { + if (s.chars <= 0) { + sideTotal += s.chars; + continue; + } + const widthPct = (s.chars / max) * 100; + const seg = DOM.append(bar, $(`span.chat-debug-cache-sig-seg.role-${roleClass(s.role)}`)); + if (s.drift) { + seg.classList.add('is-drift'); + } + seg.style.width = `${widthPct}%`; + seg.title = `${s.label}: ${numberFormatter.value.format(s.chars)} chars` + (s.drift ? ` \u2014 drift` : ''); + if (s.chars > max * 0.05) { + seg.textContent = `${s.label}:${numberFormatter.value.format(s.chars)}`; + } + sideTotal += s.chars; + } + // Pad the lane so both sides share the same x scale. + if (sideTotal < max) { + const pad = DOM.append(bar, $('span.chat-debug-cache-sig-seg.role-empty')); + pad.style.width = `${((max - sideTotal) / max) * 100}%`; + } + if (breakPos !== undefined && diff.break) { + const line = DOM.append(bar, $('.chat-debug-cache-sig-break')); + line.style.left = `${(breakPos / max) * 100}%`; + line.title = localize('chatDebug.cache.breakLineTooltip', "Cache break at messages[{0}]", diff.break.index); + } + DOM.append(row, $('.chat-debug-cache-sig-lane-total', undefined, localize('chatDebug.cache.charsTotal', "{0} chars", numberFormatter.value.format(sideTotal)))); + return row; + }; + + const lanes = DOM.append(section, $('.chat-debug-cache-sig-lanes')); + lanes.appendChild(buildLane(localize('chatDebug.cache.lanePrevious', "Previous"), aSegs, breakCharPos(aSegs))); + lanes.appendChild(buildLane(localize('chatDebug.cache.laneCurrent', "Current"), bSegs, breakCharPos(bSegs))); + + // Single-line text summary below the bars. + let shared = 0; + for (const tok of diff.signature) { + if (tok.kind === CacheDiffKind.Identical) { + shared += tok.bCharLength ?? 0; + } else { + break; + } + } + if (a.system && a.system === b.system) { + shared += a.system.length; + } + const summary = DOM.append(section, $('.chat-debug-cache-sig-summary')); + if (diff.break) { + summary.textContent = localize('chatDebug.cache.signatureSummaryBreak', + "{0} of {1} chars reused \u00b7 break at messages[{2}]", + numberFormatter.value.format(shared), + numberFormatter.value.format(totalB), + diff.break.index, + ); + } else { + summary.textContent = localize('chatDebug.cache.signatureSummaryClean', + "{0} of {1} chars reused \u00b7 no divergence detected", + numberFormatter.value.format(shared), + numberFormatter.value.format(totalB), + ); + } + } + + /** + * Render the per-key request-options table. Shows every cache-keying + * option captured from the model provider request body, with a column + * for the previous turn and one for the current turn. Rows whose + * values differ are highlighted. + */ + private renderRequestOptions(a: ISideData, b: ISideData): void { + const prev = sideOptions(a); + const curr = sideOptions(b); + const keys = new Set([...Object.keys(prev), ...Object.keys(curr)]); + if (keys.size === 0) { + return; + } + + const section = DOM.append(this.content, $('.chat-debug-cache-section')); + DOM.append(section, $('h3.chat-debug-cache-section-h', undefined, localize('chatDebug.cache.requestOptionsHeading', "Request Options"))); + + const table = DOM.append(section, $('.chat-debug-cache-options-table')); + const head = DOM.append(table, $('.chat-debug-cache-options-row.head')); + DOM.append(head, $('.chat-debug-cache-options-cell.key', undefined, localize('chatDebug.cache.optionsKey', "Option"))); + DOM.append(head, $('.chat-debug-cache-options-cell', undefined, localize('chatDebug.cache.optionsPrev', "Previous"))); + DOM.append(head, $('.chat-debug-cache-options-cell', undefined, localize('chatDebug.cache.optionsCurr', "Current"))); + + const sortedKeys = [...keys].sort((x, y) => x.localeCompare(y)); + for (const key of sortedKeys) { + const row = DOM.append(table, $('.chat-debug-cache-options-row')); + const av = prev[key]; + const bv = curr[key]; + const changed = !equals(av, bv); + if (changed) { + row.classList.add('changed'); + } + DOM.append(row, $('.chat-debug-cache-options-cell.key', undefined, key)); + DOM.append(row, $('.chat-debug-cache-options-cell', undefined, formatOptionValue(av))); + DOM.append(row, $('.chat-debug-cache-options-cell', undefined, formatOptionValue(bv))); + } + } + + private renderComponents(drift: readonly IComponentDrift[], a: ISideData, b: ISideData): void { + const section = DOM.append(this.content, $('.chat-debug-cache-section')); + DOM.append(section, $('h3.chat-debug-cache-section-h', undefined, localize('chatDebug.cache.componentsHeading', "Components"))); + const acc = DOM.append(section, $('.chat-debug-cache-acc')); + + if (drift.length === 0) { + const empty = DOM.append(acc, $('.chat-debug-cache-acc-empty')); + empty.textContent = localize('chatDebug.cache.allComponentsIdentical', "All components are identical between A and B."); + return; + } + + for (const c of drift) { + const item = DOM.append(acc, $('.chat-debug-cache-acc-item')); + if (this.openComponents.has(c.name)) { item.classList.add('open'); } + const head = DOM.append(item, $('.chat-debug-cache-acc-head')); + DOM.append(head, $('span.chat-debug-cache-chev')); + const name = DOM.append(head, $('.chat-debug-cache-acc-name')); + if (c.role) { DOM.append(name, $('span.role', undefined, c.role)); } + DOM.append(name, DOM.$('span', undefined, c.name)); + const badge = DOM.append(head, $(`span.chat-debug-cache-acc-badge.${c.status}`)); + badge.textContent = badgeLabel(c.status); + const sizes = DOM.append(head, $('span.chat-debug-cache-acc-sizes')); + sizes.textContent = `${formatTokens(c.aSize)} → ${formatTokens(c.bSize)} B`; + + const body = DOM.append(item, $('.chat-debug-cache-acc-body')); + const aText = textForComponent(c, a); + const bText = textForComponent(c, b); + body.appendChild(this.renderComponentDiff(aText, bText, c.aSize, c.bSize)); + + this.loadDisposables.add(DOM.addDisposableListener(head, DOM.EventType.CLICK, () => { + if (this.openComponents.has(c.name)) { + this.openComponents.delete(c.name); + item.classList.remove('open'); + } else { + this.openComponents.add(c.name); + item.classList.add('open'); + } + })); + } + } + + private renderComponentDiff(aText: string, bText: string, aSize: number, bSize: number): HTMLElement { + const grid = $('.chat-debug-cache-diff'); + const colA = DOM.append(grid, $('.chat-debug-cache-diff-col')); + DOM.append(colA, $('h4', undefined, localize('chatDebug.cache.diffSideA', "Previous \u00b7 {0} chars", numberFormatter.value.format(aSize)))); + const aBody = DOM.append(colA, $('.chat-debug-cache-diff-body')); + + const colB = DOM.append(grid, $('.chat-debug-cache-diff-col')); + DOM.append(colB, $('h4', undefined, localize('chatDebug.cache.diffSideB', "Current \u00b7 {0} chars", numberFormatter.value.format(bSize)))); + const bBody = DOM.append(colB, $('.chat-debug-cache-diff-body')); + + if (!aText && !bText) { + aBody.textContent = localize('chatDebug.cache.notPresent', "(not present)"); + bBody.textContent = localize('chatDebug.cache.notPresent', "(not present)"); + return grid; + } + + renderInlineDiff(aBody, bBody, aText, bText); + return grid; + } +} + +function findSection(sections: readonly IChatDebugMessageSection[] | undefined, name: string): string | undefined { + if (!sections) { + return undefined; + } + for (const s of sections) { + if (s.name === name) { + return s.content; + } + } + return undefined; +} + +/** + * Group model turns by request — turns that share the same `parentEventId` + * belong to the same agent invocation (one user prompt). The group key is + * used as the request id surfaced in the rail header. + */ +function buildTurnGroups(turns: readonly IChatDebugModelTurnEvent[], userMessages: readonly IChatDebugUserMessageEvent[]): readonly ITurnGroup[] { + // Index user messages by their span id (and the live `user-msg-` prefixed variant). + const userById = new Map(); + for (const um of userMessages) { + if (!um.id) { + continue; + } + userById.set(um.id, um); + const stripped = um.id.startsWith('user-msg-') ? um.id.slice('user-msg-'.length) : um.id; + userById.set(stripped, um); + } + + const groups = new Map(); + const order: string[] = []; + turns.forEach((turn, index) => { + const key = turn.parentEventId ?? turn.id ?? `turn-${index}`; + let entry = groups.get(key); + if (!entry) { + entry = { userMessage: userById.get(key) ?? userById.get(`user-msg-${key}`), turns: [] }; + groups.set(key, entry); + order.push(key); + } + entry.turns.push({ turn, index }); + }); + return order.map(key => ({ key, userMessage: groups.get(key)!.userMessage, turns: groups.get(key)!.turns })); +} + +function textForComponent(c: IComponentDrift, side: ISideData): string { + if (c.name === 'system') { + return side.system ?? ''; + } + const m = /^messages\[(\d+)\]$/.exec(c.name); + if (m) { + const idx = parseInt(m[1], 10); + return side.inputMessages[idx]?.text ?? ''; + } + return ''; +} + +function badgeLabel(status: CacheDiffKind): string { + switch (status) { + case CacheDiffKind.Identical: return localize('chatDebug.cache.badge.identical', "identical"); + case CacheDiffKind.ContentDrift: return localize('chatDebug.cache.badge.contentDrift', "content drift"); + case CacheDiffKind.LengthChange: return localize('chatDebug.cache.badge.lengthChange', "length change"); + case CacheDiffKind.OnlyInA: return localize('chatDebug.cache.badge.onlyA', "only in A"); + case CacheDiffKind.OnlyInB: return localize('chatDebug.cache.badge.onlyB', "only in B"); + } +} + +/** + * One-line human-readable description of the kind of change at the cache + * break, including the role and size of the divergent message when known. + */ +function describeBreakKind(kind: Exclude, diff: ICacheDiffResult, b: ISideData): string { + const tok = diff.signature.find(t => t.index === diff.break?.index); + const role = tok?.bRole ?? tok?.aRole ?? 'message'; + const bMsg = b.inputMessages[diff.break?.index ?? -1]; + const charsB = bMsg ? numberFormatter.value.format(bMsg.charLength) : undefined; + switch (kind) { + case CacheDiffKind.OnlyInB: + return charsB + ? localize('chatDebug.cache.kind.added', "added {0} message ({1} chars)", role, charsB) + : localize('chatDebug.cache.kind.addedNoSize', "added {0} message", role); + case CacheDiffKind.OnlyInA: + return localize('chatDebug.cache.kind.dropped', "previous {0} message dropped", role); + case CacheDiffKind.ContentDrift: + return charsB + ? localize('chatDebug.cache.kind.contentDrift', "{0} message body changed ({1} chars)", role, charsB) + : localize('chatDebug.cache.kind.contentDriftNoSize', "{0} message body changed", role); + case CacheDiffKind.LengthChange: + return charsB + ? localize('chatDebug.cache.kind.lengthChange', "{0} message resized to {1} chars", role, charsB) + : localize('chatDebug.cache.kind.lengthChangeNoSize', "{0} message size changed", role); + } +} + +function computeCacheHit(event: IChatDebugModelTurnEvent): number { + if (!event.inputTokens || event.cachedTokens === undefined) { + return 0; + } + return Math.min(100, (event.cachedTokens / event.inputTokens) * 100); +} + +/** + * Maps a normalized message role onto the small set of CSS color classes + * the prompt-signature visualization recognizes. Unknown roles fall through + * to `tool` so they still get a swatch. + */ +function roleClass(role: string): string { + switch (role) { + case 'system': + case 'user': + case 'assistant': + case 'tool': + return role; + default: + return 'tool'; + } +} + +/** + * Format a cache hit percentage with 2-decimal precision, truncating rather + * than rounding so a value like 99.998% does not display as 100%. We only + * report a literal `100%` when the ratio is exactly 1. + */ +function formatCachePct(pct: number): string { + const truncated = Math.floor(pct * 100) / 100; + return truncated.toFixed(2); +} + +/** + * Integer-precision variant of {@link formatCachePct} for the rail chip. + */ +function formatCachePctInt(pct: number): string { + return String(Math.floor(pct)); +} + +function formatTokens(value: number | undefined): string { + if (value === undefined) { + return '\u2014'; + } + return numberFormatter.value.format(value); +} + +interface IOptionDelta { + readonly key: string; + readonly previous: unknown; + readonly current: unknown; +} + +/** + * Build the cache-relevant options table for one side. Combines the + * request body's `request_options` blob with the model id surfaced on + * the OTel chat span, since switching models is the most aggressive + * cache invalidator and users expect to see it here. + */ +function sideOptions(side: ISideData): Record { + const out: Record = {}; + if (side.event.model !== undefined) { + out.model = side.event.model; + } + Object.assign(out, parseOptions(side.content?.requestOptions)); + return out; +} + +/** + * Compute the per-key delta between two requests' option tables. + * Keys are flattened one level deep so nested objects (e.g. + * `reasoning.effort`) show up with their own row instead of dumping the + * full object onto one line. The result is sorted by key for stable + * rendering. + */ +function computeOptionsDiff(a: ISideData, b: ISideData): readonly IOptionDelta[] { + const prev = sideOptions(a); + const curr = sideOptions(b); + const keys = new Set([...Object.keys(prev), ...Object.keys(curr)]); + const out: IOptionDelta[] = []; + for (const key of keys) { + const av = prev[key]; + const bv = curr[key]; + if (!equals(av, bv)) { + out.push({ key, previous: av, current: bv }); + } + } + out.sort((x, y) => x.key.localeCompare(y.key)); + return out; +} + +function parseOptions(blob: string | undefined): Record { + if (!blob) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(blob); + } catch { + return {}; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + const flat: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const [nk, nv] of Object.entries(v as Record)) { + flat[`${k}.${nk}`] = nv; + } + } else { + flat[k] = v; + } + } + return flat; +} + +function formatOptionValue(value: unknown): string { + if (value === undefined) { + return '\u2014'; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Cache "expiration" heuristic. The provider doesn't tell us *why* it + * invalidated a cache entry, so this is a best-effort guess: if the + * structural diff says the prompt prefix is byte-identical AND the + * request options match AND the model still reports 0 cached input + * tokens, expiration is the most likely cause. Other causes we cannot + * distinguish from this signal alone include provider-side eviction + * under cache pressure, server-side restarts, and per-tenant quota + * resets. The headline copy in the UI says "likely" for that reason. + */ +function isLikelyCacheExpiration(hitPct: number, diff: ICacheDiffResult, optionsDiff: readonly IOptionDelta[]): boolean { + if (hitPct >= 1) { + return false; + } + if (diff.break) { + return false; + } + if (optionsDiff.length > 0) { + return false; + } + return true; +} + +const DIFF_OPTIONS = { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 200, + computeMoves: false, +} as const; + +/** + * Render a side-by-side line + character diff into the two body elements. + * + * Uses {@link linesDiffComputers.getDefault()} to compute a line-level diff + * with inner character-level mappings, then walks the result to emit one + * div per line. Lines belonging to a removed range are styled with the + * "remove" class on the previous side; added ranges with the "add" class + * on the current side; modified ranges appear on both sides with character + * spans highlighted within. Identical lines are placed on both sides as + * context. + */ +function renderInlineDiff(prevHost: HTMLElement, currHost: HTMLElement, prev: string, curr: string): void { + const prevLines = prev.split(/\r?\n/); + const currLines = curr.split(/\r?\n/); + const result = linesDiffComputers.getDefault().computeDiff(prevLines, currLines, DIFF_OPTIONS); + + let prevIdx = 0; + let currIdx = 0; + for (const change of result.changes) { + const origStart = change.original.startLineNumber; + const origEnd = change.original.endLineNumberExclusive; + const modStart = change.modified.startLineNumber; + const modEnd = change.modified.endLineNumberExclusive; + + // Emit identical context lines up to this change. + while (prevIdx + 1 < origStart && currIdx + 1 < modStart) { + appendLine(prevHost, prevLines[prevIdx], 'context'); + appendLine(currHost, currLines[currIdx], 'context'); + prevIdx++; + currIdx++; + } + + // Emit changed lines on each side. Inner range mappings give us + // character-level spans; we apply them per line. + const innerByOrig = groupInnerChangesByLine(change.innerChanges, /* original */ true); + const innerByMod = groupInnerChangesByLine(change.innerChanges, /* original */ false); + + for (let line = origStart; line < origEnd; line++) { + const lineText = prevLines[line - 1] ?? ''; + appendChangedLine(prevHost, lineText, innerByOrig.get(line), 'remove'); + } + prevIdx = origEnd - 1; + + for (let line = modStart; line < modEnd; line++) { + const lineText = currLines[line - 1] ?? ''; + appendChangedLine(currHost, lineText, innerByMod.get(line), 'add'); + } + currIdx = modEnd - 1; + } + + // Emit any trailing identical context. The line-level diff guarantees + // every change range is reported, so anything left over on both sides + // after the last change is identical context — the `&&` is intentional: + // if one side has more lines than the other at this point the overflow + // is already covered by the change ranges above (otherwise we'd have a + // bug in the diff computer). + while (prevIdx < prevLines.length && currIdx < currLines.length) { + appendLine(prevHost, prevLines[prevIdx], 'context'); + appendLine(currHost, currLines[currIdx], 'context'); + prevIdx++; + currIdx++; + } +} + +function appendLine(host: HTMLElement, text: string, kind: 'context' | 'add' | 'remove'): void { + const line = DOM.append(host, $(`.chat-debug-cache-diff-line.${kind}`)); + line.textContent = text === '' ? '\u00a0' : text; +} + +interface IInnerChangeRange { + readonly startColumn: number; + readonly endColumn: number; +} + +function appendChangedLine(host: HTMLElement, text: string, ranges: readonly IInnerChangeRange[] | undefined, kind: 'add' | 'remove'): void { + const line = DOM.append(host, $(`.chat-debug-cache-diff-line.${kind}`)); + if (!ranges || ranges.length === 0) { + line.textContent = text === '' ? '\u00a0' : text; + return; + } + let cursor = 1; // 1-based column index + const sorted = [...ranges].sort((a, b) => a.startColumn - b.startColumn); + for (const r of sorted) { + if (r.startColumn > cursor) { + DOM.append(line, document.createTextNode(text.substring(cursor - 1, r.startColumn - 1))); + } + const span = DOM.append(line, $('span.chat-debug-cache-diff-inner')); + span.textContent = text.substring(r.startColumn - 1, r.endColumn - 1); + cursor = r.endColumn; + } + if (cursor - 1 < text.length) { + DOM.append(line, document.createTextNode(text.substring(cursor - 1))); + } +} + +/** + * Group {@link DetailedLineRangeMapping.innerChanges} by line so the diff + * renderer can look up character ranges per line. Multi-line range + * mappings only contribute a partial range to their first/last line; we + * approximate by clamping to the line bounds. + */ +function groupInnerChangesByLine( + innerChanges: readonly RangeMapping[] | undefined, + useOriginal: boolean, +): Map { + const out = new Map(); + if (!innerChanges) { + return out; + } + for (const r of innerChanges) { + const range = useOriginal ? r.originalRange : r.modifiedRange; + // Only handle single-line inner ranges for v1. Multi-line spans + // are flagged at the line level via the surrounding add/remove + // styling, so we don't need pixel-perfect column highlights. + if (range.startLineNumber !== range.endLineNumber) { + continue; + } + const list = out.get(range.startLineNumber) ?? []; + list.push({ startColumn: range.startColumn, endColumn: range.endColumn }); + out.set(range.startLineNumber, list); + } + return out; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 67f5d8de3b0dc..231ab36af2edc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -31,6 +31,7 @@ import { ChatDebugHomeView } from './chatDebugHomeView.js'; import { ChatDebugOverviewView, OverviewNavigation } from './chatDebugOverviewView.js'; import { ChatDebugLogsView, LogsNavigation } from './chatDebugLogsView.js'; import { ChatDebugFlowChartView, FlowChartNavigation } from './chatDebugFlowChartView.js'; +import { ChatDebugCacheExplorerView, CacheExplorerNavigation } from './chatDebugCacheExplorerView.js'; const $ = DOM.$; @@ -44,7 +45,7 @@ type ChatDebugViewSwitchedEvent = { }; type ChatDebugViewSwitchedClassification = { - viewState: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The view the user navigated to (home, overview, logs, flowchart).' }; + viewState: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The view the user navigated to (home, overview, logs, flowchart, cache).' }; owner: 'vijayu'; comment: 'Tracks which views users navigate to in the Agent Debug Logs.'; }; @@ -62,6 +63,7 @@ export class ChatDebugEditor extends EditorPane { private overviewView: ChatDebugOverviewView | undefined; private logsView: ChatDebugLogsView | undefined; private flowChartView: ChatDebugFlowChartView | undefined; + private cacheExplorerView: ChatDebugCacheExplorerView | undefined; private filterState: ChatDebugFilterState | undefined; private readonly sessionModelListener = this._register(new MutableDisposable()); @@ -122,6 +124,9 @@ export class ChatDebugEditor extends EditorPane { case OverviewNavigation.FlowChart: this.showView(ViewState.FlowChart); break; + case OverviewNavigation.CacheExplorer: + this.showView(ViewState.CacheExplorer); + break; } })); @@ -151,6 +156,19 @@ export class ChatDebugEditor extends EditorPane { } })); + this.cacheExplorerView = this._register(this.instantiationService.createInstance(ChatDebugCacheExplorerView, this.container)); + this._register(this.cacheExplorerView.onNavigate(nav => { + switch (nav) { + case CacheExplorerNavigation.Home: + this.endActiveSession(); + this.showView(ViewState.Home); + break; + case CacheExplorerNavigation.Overview: + this.showView(ViewState.Overview); + break; + } + })); + // When new debug events arrive, refresh the active session view this._register(this.chatDebugService.onDidAddEvent(event => { if (this.viewState === ViewState.Home) { @@ -160,6 +178,8 @@ export class ChatDebugEditor extends EditorPane { this.overviewView?.refresh(); } else if (this.viewState === ViewState.FlowChart) { this.flowChartView?.refresh(); + } else if (this.viewState === ViewState.CacheExplorer) { + this.cacheExplorerView?.refresh(); } // Note: Logs view is intentionally omitted here — it handles // onDidAddEvent internally via loadEvents() → addEvent() → @@ -182,10 +202,11 @@ export class ChatDebugEditor extends EditorPane { if (e.kind === 'setCustomTitle') { if (this.viewState === ViewState.Home) { this.homeView?.render(); - } else if (this.viewState === ViewState.Overview || this.viewState === ViewState.Logs || this.viewState === ViewState.FlowChart) { + } else if (this.viewState === ViewState.Overview || this.viewState === ViewState.Logs || this.viewState === ViewState.FlowChart || this.viewState === ViewState.CacheExplorer) { this.overviewView?.updateBreadcrumb(); this.logsView?.updateBreadcrumb(); this.flowChartView?.updateBreadcrumb(); + this.cacheExplorerView?.updateBreadcrumb(); } } })); @@ -237,9 +258,15 @@ export class ChatDebugEditor extends EditorPane { this.flowChartView?.hide(); } + if (state === ViewState.CacheExplorer) { + this.cacheExplorerView?.show(); + } else { + this.cacheExplorerView?.hide(); + } + } - navigateToSession(sessionResource: URI, view?: 'logs' | 'overview' | 'flowchart'): void { + navigateToSession(sessionResource: URI, view?: 'logs' | 'overview' | 'flowchart' | 'cache'): void { // End the previous session's streaming pipeline before switching const previousSessionResource = this.chatDebugService.activeSessionResource; if (previousSessionResource && previousSessionResource.toString() !== sessionResource.toString()) { @@ -255,8 +282,13 @@ export class ChatDebugEditor extends EditorPane { this.overviewView?.setSession(sessionResource); this.logsView?.setSession(sessionResource); this.flowChartView?.setSession(sessionResource); + this.cacheExplorerView?.setSession(sessionResource); - this.showView(view === 'logs' ? ViewState.Logs : view === 'flowchart' ? ViewState.FlowChart : ViewState.Overview); + const targetState = view === 'logs' ? ViewState.Logs + : view === 'flowchart' ? ViewState.FlowChart + : view === 'cache' ? ViewState.CacheExplorer + : ViewState.Overview; + this.showView(targetState); } private trackSessionModelChanges(sessionResource: URI): void { @@ -331,6 +363,8 @@ export class ChatDebugEditor extends EditorPane { this.navigateToSession(sessionResource, 'logs'); } else if (viewHint === 'flowchart' && sessionResource) { this.navigateToSession(sessionResource, 'flowchart'); + } else if (viewHint === 'cache' && sessionResource) { + this.navigateToSession(sessionResource, 'cache'); } else if (viewHint === 'overview' && sessionResource) { this.navigateToSession(sessionResource, 'overview'); } else if (viewHint === 'home') { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index e6ff9e8cd7607..9880f2c6b2273 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -784,19 +784,19 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { if (event.model) { parts.push(event.model); } - if (event.totalTokens) { + if (event.totalTokens !== undefined) { parts.push(localize('tooltipTokens', "Tokens: {0}", event.totalTokens)); } - if (event.inputTokens) { + if (event.inputTokens !== undefined) { parts.push(localize('tooltipInputTokens', "Input tokens: {0}", event.inputTokens)); } - if (event.outputTokens) { + if (event.outputTokens !== undefined) { parts.push(localize('tooltipOutputTokens', "Output tokens: {0}", event.outputTokens)); } if (event.cachedTokens !== undefined) { parts.push(localize('tooltipCachedTokens', "Cached tokens: {0}", event.cachedTokens)); } - if (event.durationInMillis) { + if (event.durationInMillis !== undefined) { parts.push(localize('tooltipDuration', "Duration: {0}", formatDuration(event.durationInMillis))); } return parts.length > 0 ? parts.join('\n') : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 4420b44fe7e0e..701ed61ed447b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -191,7 +191,7 @@ export class ChatDebugLogsView extends Disposable { case 'toolCall': return localize('chatDebug.aria.toolCall', "Tool call: {0}{1}", e.toolName, e.result ? ` (${e.result})` : ''); case 'modelTurn': return localize('chatDebug.aria.modelTurn', "Model turn: {0}{1}{2}", e.model ?? localize('chatDebug.aria.model', "model"), - e.totalTokens ? localize('chatDebug.aria.tokenCount', " {0} tokens", e.totalTokens) : '', + e.totalTokens !== undefined ? localize('chatDebug.aria.tokenCount', " {0} tokens", e.totalTokens) : '', e.cachedTokens !== undefined ? localize('chatDebug.aria.cachedTokens', " {0} cached", e.cachedTokens) : ''); case 'generic': return `${e.category ? e.category + ': ' : ''}${e.name}: ${e.details ?? ''}`; case 'subagentInvocation': return localize('chatDebug.aria.subagent', "Subagent: {0}{1}", e.agentName, e.description ? ` - ${e.description}` : ''); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts index 62f14be344ccd..37096436877d5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -30,6 +30,7 @@ export const enum OverviewNavigation { Home = 'home', Logs = 'logs', FlowChart = 'flowchart', + CacheExplorer = 'cache', } export class ChatDebugOverviewView extends Disposable { @@ -252,6 +253,13 @@ export class ChatDebugOverviewView extends Disposable { this._onNavigate.fire(OverviewNavigation.FlowChart); })); + const cacheBtn = this.loadDisposables.add(new Button(row, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: localize('chatDebug.cacheExplorer', "Cache Explorer") })); + cacheBtn.element.classList.add('chat-debug-overview-action-button'); + cacheBtn.label = `$(database) ${localize('chatDebug.cacheExplorer', "Cache Explorer")}`; + this.loadDisposables.add(cacheBtn.onDidClick(() => { + this._onNavigate.fire(OverviewNavigation.CacheExplorer); + })); + } private renderMetricsShimmer(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index a6ac1bc979972..8590fdae4690a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -18,7 +18,7 @@ const $ = DOM.$; */ export interface IChatDebugEditorOptions extends IEditorOptions { readonly sessionResource?: URI; - readonly viewHint?: 'home' | 'overview' | 'logs' | 'flowchart'; + readonly viewHint?: 'home' | 'overview' | 'logs' | 'flowchart' | 'cache'; /** When set, automatically applies this text as the log filter. */ readonly filter?: string; } @@ -28,6 +28,7 @@ export const enum ViewState { Overview = 'overview', Logs = 'logs', FlowChart = 'flowchart', + CacheExplorer = 'cache', } export const enum LogsViewMode { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index a38b0f0d6664b..df50e251d5ee0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -108,8 +108,12 @@ } @keyframes chat-debug-shimmer { - 0% { background-position: 120% 0; } - 100% { background-position: -120% 0; } + 0% { + background-position: 120% 0; + } + 100% { + background-position: -120% 0; + } } /* ---- Breadcrumb ---- */ @@ -409,17 +413,21 @@ } .chat-debug-log-row.chat-debug-log-error, .chat-debug-log-row.chat-debug-log-error:hover { - background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)) !important; + background-color: var(--vscode-inputValidation-errorBackground, + rgba(255, 0, 0, 0.1)) !important; color: var(--vscode-errorForeground) !important; } .monaco-tl-row:has(.chat-debug-log-row.chat-debug-log-error) { - background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)) !important; + background-color: var(--vscode-inputValidation-errorBackground, + rgba(255, 0, 0, 0.1)) !important; } .chat-debug-log-row.chat-debug-log-warning { - background-color: var(--vscode-inputValidation-warningBackground, rgba(255, 204, 0, 0.1)) !important; + background-color: var(--vscode-inputValidation-warningBackground, + rgba(255, 204, 0, 0.1)) !important; } .monaco-tl-row:has(.chat-debug-log-row.chat-debug-log-warning) { - background-color: var(--vscode-inputValidation-warningBackground, rgba(255, 204, 0, 0.1)) !important; + background-color: var(--vscode-inputValidation-warningBackground, + rgba(255, 204, 0, 0.1)) !important; } .chat-debug-log-row.chat-debug-log-trace { opacity: 0.7; @@ -788,3 +796,593 @@ justify-content: center; margin: 12px 0 0; } + +/* ---- Cache Explorer view ---- */ +.chat-debug-cache { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-height: 0; +} +.chat-debug-cache-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + position: relative; +} +.chat-debug-cache-rail { + border-right: 1px solid var(--vscode-widget-border, transparent); + background: var(--vscode-sideBar-background); + display: flex; + flex-direction: column; + min-height: 0; + flex-shrink: 0; + overflow: hidden; +} +.chat-debug-cache-rail-list { + flex: 1 1 auto; + overflow-y: auto; + padding: 4px 0; +} +.chat-debug-cache-group-header { + padding: 10px 10px 4px 10px; + border-top: 1px solid var(--vscode-widget-border, transparent); + display: flex; + flex-direction: column; + gap: 2px; + cursor: pointer; + user-select: none; +} +.chat-debug-cache-group-header:hover { + background: var(--vscode-list-hoverBackground); +} +.chat-debug-cache-group-header:focus, +.chat-debug-cache-group-header:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} +.chat-debug-cache-rail-list > .chat-debug-cache-group-header:first-child { + border-top: none; +} +.chat-debug-cache-group-top { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.chat-debug-cache-group-chev::before { + content: "\25BE"; + display: inline-block; + width: 10px; + color: var(--vscode-descriptionForeground); + font-size: 10px; + transition: transform 0.15s; +} +.chat-debug-cache-group-header.is-collapsed .chat-debug-cache-group-chev::before { + transform: rotate(-90deg); +} +.chat-debug-cache-group-prompt { + font-size: 11.5px; + font-weight: 600; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; +} +.chat-debug-cache-group-count { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-badge-foreground); + background: var(--vscode-badge-background); + padding: 1px 7px; + border-radius: 10px; + flex-shrink: 0; +} +.chat-debug-cache-group-meta { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 16px; +} +.chat-debug-cache-request-id { + font-family: var(--monaco-monospace-font); + user-select: text; + cursor: text; + overflow-wrap: anywhere; + text-align: right; +} +.chat-debug-cache-turn { + padding: 6px 10px; + display: grid; + grid-template-columns: 24px 1fr; + gap: 2px 8px; + align-items: start; + cursor: pointer; + border-left: 3px solid transparent; +} +.chat-debug-cache-turn:hover { + background: var(--vscode-list-hoverBackground); +} +.chat-debug-cache-turn:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} +.chat-debug-cache-turn.is-selected { + border-left-color: var(--vscode-focusBorder); + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} +.chat-debug-cache-turn-idx { + font-family: var(--monaco-monospace-font); + color: var(--vscode-descriptionForeground); + font-size: 11px; + text-align: right; + padding-top: 1px; +} +.chat-debug-cache-turn-main { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.chat-debug-cache-turn-top { + font-size: 11.5px; + color: var(--vscode-foreground); + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px; + min-width: 0; +} +.chat-debug-cache-turn-source { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} +.chat-debug-cache-turn-chip { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-descriptionForeground); +} +.chat-debug-cache-turn-hit.is-bad { + color: var(--vscode-charts-red); + font-weight: 600; +} +.chat-debug-cache-turn-sub { + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-cache-content { + overflow-y: auto; + padding: 16px 24px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + flex: 1 1 auto; +} +.chat-debug-cache-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--vscode-descriptionForeground); +} +.chat-debug-cache-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.chat-debug-cache-title { + margin: 0; + font-size: 14px; + color: var(--vscode-foreground); +} +.chat-debug-cache-title-actions { + display: flex; + gap: 6px; +} +.chat-debug-cache-summary { + display: grid; + grid-template-columns: 1fr 1fr 1.5fr; + gap: 12px; +} +.chat-debug-cache-card { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); + border-radius: 6px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} +.chat-debug-cache-card.break { + border-color: var(--vscode-charts-red); +} +.chat-debug-cache-card-h { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.chat-debug-cache-card-headline { + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); +} +.chat-debug-cache-card-sub { + font-size: 11.5px; + color: var(--vscode-foreground); +} +.chat-debug-cache-perf-rule { + height: 1px; + background: var(--vscode-widget-border, var(--vscode-editorWidget-border)); + margin: 12px 0 4px; +} +.chat-debug-cache-perf-section-h { + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} +.chat-debug-cache-perf-line { + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + color: var(--vscode-foreground); + line-height: 1.5; +} +.chat-debug-cache-options-banner { + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + color: var(--vscode-charts-yellow); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-charts-yellow); + border-radius: 4px; + padding: 6px 10px; +} +.chat-debug-cache-options-table { + display: flex; + flex-direction: column; + border: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); + border-radius: 6px; + overflow: hidden; +} +.chat-debug-cache-options-row { + display: grid; + grid-template-columns: 200px 1fr 1fr; + gap: 12px; + padding: 6px 12px; + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + border-top: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); +} +.chat-debug-cache-options-row:first-child { + border-top: none; +} +.chat-debug-cache-options-row.head { + background: var(--vscode-editorWidget-background); + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + font-size: 10.5px; + letter-spacing: 0.05em; +} +.chat-debug-cache-options-row.changed { + background: var(--vscode-diffEditor-removedLineBackground, rgba(244, 135, 113, 0.08)); +} +.chat-debug-cache-options-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-cache-options-cell.key { + color: var(--vscode-descriptionForeground); +} +.chat-debug-cache-kv { + display: flex; + justify-content: space-between; + gap: 12px; + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + flex-wrap: wrap; +} +.chat-debug-cache-kv .k { + color: var(--vscode-descriptionForeground); + flex-shrink: 0; +} +.chat-debug-cache-kv .v { + color: var(--vscode-foreground); + min-width: 0; +} +.chat-debug-cache-section-h { + margin: 0 0 8px; + font-size: 12px; + color: var(--vscode-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.chat-debug-cache-sig-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; +} +.chat-debug-cache-sig-legend-entry { + display: inline-flex; + align-items: center; + gap: 6px; +} +.chat-debug-cache-sig-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; +} +.chat-debug-cache-sig-swatch.role-system { + background: var(--vscode-charts-purple, #b292ff); +} +.chat-debug-cache-sig-swatch.role-user { + background: var(--vscode-charts-blue); +} +.chat-debug-cache-sig-swatch.role-assistant { + background: var(--vscode-charts-green); +} +.chat-debug-cache-sig-swatch.role-tool { + background: var(--vscode-charts-yellow); +} +.chat-debug-cache-sig-swatch.role-drift { + background: transparent; + outline: 2px solid var(--vscode-charts-red); + outline-offset: -2px; +} +.chat-debug-cache-sig-lanes { + display: flex; + flex-direction: column; + gap: 6px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); + border-radius: 6px; + padding: 12px 14px; +} +.chat-debug-cache-sig-lane-row { + display: grid; + grid-template-columns: 70px 1fr 110px; + gap: 10px; + align-items: center; +} +.chat-debug-cache-sig-lane-label { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.chat-debug-cache-sig-lane-total { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + text-align: right; +} +.chat-debug-cache-sig-bar { + height: 22px; + background: var(--vscode-input-background, #2a2a2a); + border-radius: 4px; + overflow: hidden; + display: flex; + position: relative; +} +.chat-debug-cache-sig-seg { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + overflow: hidden; + white-space: nowrap; + border-right: 1px solid rgba(0, 0, 0, 0.3); + transition: filter 0.1s; +} +.chat-debug-cache-sig-seg:hover { + filter: brightness(1.2); +} +.chat-debug-cache-sig-seg.role-system { + background: var(--vscode-charts-purple, #b292ff); + color: #1a0e30; +} +.chat-debug-cache-sig-seg.role-user { + background: var(--vscode-charts-blue); + color: #06243d; +} +.chat-debug-cache-sig-seg.role-assistant { + background: var(--vscode-charts-green); + color: #082b0c; +} +.chat-debug-cache-sig-seg.role-tool { + background: var(--vscode-charts-yellow); + color: #2a1d00; +} +.chat-debug-cache-sig-seg.role-empty { + background: transparent; + border-right: none; +} +.chat-debug-cache-sig-seg.is-drift { + outline: 2px solid var(--vscode-charts-red); + outline-offset: -2px; + filter: brightness(0.85); +} +.chat-debug-cache-sig-break { + position: absolute; + top: -2px; + bottom: -2px; + border-left: 2px dashed var(--vscode-charts-red); + pointer-events: none; +} +.chat-debug-cache-sig-summary { + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + color: var(--vscode-foreground); + margin-top: 8px; +} +.chat-debug-cache-acc { + display: flex; + flex-direction: column; + gap: 4px; +} +.chat-debug-cache-acc-empty { + font-size: 11.5px; + color: var(--vscode-descriptionForeground); +} +.chat-debug-cache-acc-item { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); + border-radius: 6px; + overflow: hidden; +} +.chat-debug-cache-acc-head { + display: grid; + grid-template-columns: 16px 1fr auto auto; + align-items: center; + gap: 10px; + padding: 8px 12px; + cursor: pointer; +} +.chat-debug-cache-acc-head:hover { + background: var(--vscode-list-hoverBackground); +} +.chat-debug-cache-chev::before { + content: "\25B6"; + display: inline-block; + color: var(--vscode-descriptionForeground); + font-size: 10px; + transition: transform 0.15s; +} +.chat-debug-cache-acc-item.open .chat-debug-cache-chev::before { + transform: rotate(90deg); +} +.chat-debug-cache-acc-name { + font-family: var(--monaco-monospace-font); + font-size: 12px; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-cache-acc-name .role { + color: var(--vscode-descriptionForeground); + margin-right: 6px; +} +.chat-debug-cache-acc-badge { + font-family: var(--monaco-monospace-font); + font-size: 10.5px; + padding: 2px 8px; + border-radius: 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} +.chat-debug-cache-acc-badge.identical { + color: var(--vscode-descriptionForeground); + background: transparent; + border: 1px solid var(--vscode-widget-border, transparent); +} +.chat-debug-cache-acc-badge.contentDrift, +.chat-debug-cache-acc-badge.lengthChange { + color: var(--vscode-charts-red); + background: transparent; + border: 1px solid var(--vscode-charts-red); +} +.chat-debug-cache-acc-badge.onlyInA { + color: var(--vscode-charts-yellow); + background: transparent; + border: 1px solid var(--vscode-charts-yellow); +} +.chat-debug-cache-acc-badge.onlyInB { + color: var(--vscode-charts-blue); + background: transparent; + border: 1px solid var(--vscode-charts-blue); +} +.chat-debug-cache-acc-sizes { + font-family: var(--monaco-monospace-font); + font-size: 11px; + color: var(--vscode-descriptionForeground); + min-width: 130px; + text-align: right; +} +.chat-debug-cache-acc-body { + display: none; + border-top: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); +} +.chat-debug-cache-acc-item.open .chat-debug-cache-acc-body { + display: block; +} +.chat-debug-cache-diff { + display: grid; + grid-template-columns: 1fr 1fr; + font-family: var(--monaco-monospace-font); + font-size: 11.5px; +} +.chat-debug-cache-diff-col { + padding: 8px 12px; + overflow-x: auto; + white-space: pre-wrap; + max-height: 320px; + overflow-y: auto; + word-break: break-word; +} +.chat-debug-cache-diff-col + .chat-debug-cache-diff-col { + border-left: 1px solid var(--vscode-widget-border, var(--vscode-editorWidget-border)); +} +.chat-debug-cache-diff-col h4 { + margin: 0 0 6px; + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + font-weight: 600; + text-transform: uppercase; +} +.chat-debug-cache-diff-body { + font-family: var(--monaco-monospace-font); + font-size: 11.5px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.chat-debug-cache-diff-line { + padding: 0 4px; + border-radius: 2px; +} +.chat-debug-cache-diff-line.add { + background: var(--vscode-diffEditor-insertedLineBackground, rgba(95, 184, 107, 0.12)); +} +.chat-debug-cache-diff-line.remove { + background: var(--vscode-diffEditor-removedLineBackground, rgba(244, 135, 113, 0.12)); +} +.chat-debug-cache-diff-line.context { + color: var(--vscode-descriptionForeground); +} +.chat-debug-cache-diff-line.add .chat-debug-cache-diff-inner { + background: var(--vscode-diffEditor-insertedTextBackground, rgba(95, 184, 107, 0.4)); + border-radius: 2px; +} +.chat-debug-cache-diff-line.remove .chat-debug-cache-diff-inner { + background: var(--vscode-diffEditor-removedTextBackground, rgba(244, 135, 113, 0.4)); + border-radius: 2px; +} diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index d24d8a392216b..b5f0b2ae2bab1 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -350,12 +350,14 @@ export interface IChatDebugEventModelTurnContent { readonly status?: string; readonly durationInMillis?: number; readonly timeToFirstTokenInMillis?: number; + readonly requestId?: string; readonly maxInputTokens?: number; readonly maxOutputTokens?: number; readonly inputTokens?: number; readonly outputTokens?: number; readonly cachedTokens?: number; readonly totalTokens?: number; + readonly requestOptions?: string; readonly errorMessage?: string; readonly sections?: readonly IChatDebugMessageSection[]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatDebugCacheDiff.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatDebugCacheDiff.test.ts new file mode 100644 index 0000000000000..575dd0083fffe --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatDebugCacheDiff.test.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { appendSystemDrift, CacheDiffKind, diffPromptSignature, formatSignatureToken, parseInputMessages } from '../../browser/chatDebug/chatDebugCacheDiff.js'; + +function msg(role: string, content: string, name?: string) { + const part: { type: string; content: string; name?: string } = { type: 'text', content }; + if (name) { + part.name = name; + } + return { role, ...(name ? { name } : {}), parts: [part] }; +} + +suite('chatDebugCacheDiff', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseInputMessages', () => { + test('parses well-formed input messages and computes byte length', () => { + const json = JSON.stringify([msg('system', 'hi'), msg('user', 'hello'), msg('tool', 'result', 'tool_a')]); + const parsed = parseInputMessages(json); + assert.deepStrictEqual(parsed, [ + { role: 'system', name: undefined, text: 'hi', charLength: 2 }, + { role: 'user', name: undefined, text: 'hello', charLength: 5 }, + { role: 'tool', name: 'tool_a', text: 'result', charLength: 6 }, + ]); + }); + + test('returns empty array for malformed inputs', () => { + assert.deepStrictEqual(parseInputMessages(undefined), []); + assert.deepStrictEqual(parseInputMessages(''), []); + assert.deepStrictEqual(parseInputMessages('not json'), []); + assert.deepStrictEqual(parseInputMessages('"a string"'), []); + }); + + test('extracts tool_call_response content and reclassifies role to tool', () => { + const json = JSON.stringify([ + { role: 'user', parts: [{ type: 'tool_call_response', id: 'call_1', response: 'Found 12 references.' }] }, + ]); + assert.deepStrictEqual(parseInputMessages(json), [ + { role: 'tool', name: undefined, text: 'Found 12 references.', charLength: 'Found 12 references.'.length }, + ]); + }); + + test('extracts tool_call arguments on assistant messages', () => { + const json = JSON.stringify([ + { role: 'assistant', parts: [{ type: 'tool_call', id: 'call_1', name: 'fs_read', arguments: { path: '/etc/hosts' } }] }, + ]); + const expected = `call:fs_read${JSON.stringify({ path: '/etc/hosts' })}`; + assert.deepStrictEqual(parseInputMessages(json), [ + { role: 'assistant', name: undefined, text: expected, charLength: expected.length }, + ]); + }); + }); + + suite('diffPromptSignature', () => { + test('all identical messages produce no break and only identical tokens', () => { + const a = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1')])); + const b = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1')])); + const result = diffPromptSignature(a, b); + assert.deepStrictEqual( + { + break: result.break, + counts: result.counts, + kinds: result.signature.map(s => s.kind), + drift: result.drift.map(d => d.name + ':' + d.status), + }, + { + break: undefined, + counts: { identical: 2, contentDrift: 0, lengthChange: 0, onlyInA: 0, onlyInB: 0 }, + kinds: [CacheDiffKind.Identical, CacheDiffKind.Identical], + drift: [], + }, + ); + }); + + test('content drift at index 1 reports a contentDrift break', () => { + const a = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'aaaa')])); + const b = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'bbbb')])); + const result = diffPromptSignature(a, b); + assert.deepStrictEqual( + { + break: result.break, + counts: result.counts, + kinds: result.signature.map(s => s.kind), + drift: result.drift.map(d => `${d.name}:${d.status}:${d.aSize}->${d.bSize}`), + }, + { + break: { index: 1, kind: CacheDiffKind.ContentDrift }, + counts: { identical: 1, contentDrift: 1, lengthChange: 0, onlyInA: 0, onlyInB: 0 }, + kinds: [CacheDiffKind.Identical, CacheDiffKind.ContentDrift], + drift: ['messages[1]:contentDrift:4->4'], + }, + ); + }); + + test('length change at index 1 reports a lengthChange break', () => { + const a = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'short')])); + const b = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'much longer text')])); + const result = diffPromptSignature(a, b); + assert.deepStrictEqual( + { + break: result.break, + counts: result.counts, + kinds: result.signature.map(s => s.kind), + drift: result.drift.map(d => `${d.name}:${d.status}:${d.aSize}->${d.bSize}`), + }, + { + break: { index: 1, kind: CacheDiffKind.LengthChange }, + counts: { identical: 1, contentDrift: 0, lengthChange: 1, onlyInA: 0, onlyInB: 0 }, + kinds: [CacheDiffKind.Identical, CacheDiffKind.LengthChange], + drift: ['messages[1]:lengthChange:5->16'], + }, + ); + }); + + test('B has trailing messages A does not — break at first onlyInB', () => { + const a = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1')])); + const b = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1'), msg('assistant', 'a1'), msg('user', 'q2')])); + const result = diffPromptSignature(a, b); + assert.deepStrictEqual( + { + break: result.break, + counts: result.counts, + kinds: result.signature.map(s => s.kind), + }, + { + break: { index: 2, kind: CacheDiffKind.OnlyInB }, + counts: { identical: 2, contentDrift: 0, lengthChange: 0, onlyInA: 0, onlyInB: 2 }, + kinds: [CacheDiffKind.Identical, CacheDiffKind.Identical, CacheDiffKind.OnlyInB, CacheDiffKind.OnlyInB], + }, + ); + }); + + test('A has trailing messages B does not — break at first onlyInA', () => { + const a = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1'), msg('assistant', 'a1')])); + const b = parseInputMessages(JSON.stringify([msg('system', 'sys'), msg('user', 'q1')])); + const result = diffPromptSignature(a, b); + assert.deepStrictEqual( + { break: result.break, counts: result.counts }, + { + break: { index: 2, kind: CacheDiffKind.OnlyInA }, + counts: { identical: 2, contentDrift: 0, lengthChange: 0, onlyInA: 1, onlyInB: 0 }, + }, + ); + }); + + test('appendSystemDrift inserts a system row when system instructions differ', () => { + const drift = appendSystemDrift([], 'old system', 'new system!!'); + assert.deepStrictEqual(drift, [{ name: 'system', status: CacheDiffKind.LengthChange, aSize: 10, bSize: 12 }]); + }); + + test('appendSystemDrift returns input unchanged when system matches', () => { + const existing = [{ name: 'messages[0]', role: 'user', status: CacheDiffKind.ContentDrift, aSize: 4, bSize: 4 }]; + assert.deepStrictEqual(appendSystemDrift(existing, 'sys', 'sys'), existing); + }); + }); + + suite('formatSignatureToken', () => { + test('formats identical, drift, and one-sided tokens', () => { + assert.strictEqual( + formatSignatureToken({ index: 0, kind: CacheDiffKind.Identical, aRole: 'user', aCharLength: 12, bRole: 'user', bCharLength: 12 }), + 'user:12', + ); + assert.strictEqual( + formatSignatureToken({ index: 1, kind: CacheDiffKind.LengthChange, aRole: 'user', aCharLength: 5, bRole: 'user', bCharLength: 8 }), + 'user:5\u21928', + ); + assert.strictEqual( + formatSignatureToken({ index: 2, kind: CacheDiffKind.OnlyInB, bRole: 'tool', bName: 'fs_read', bCharLength: 320 }), + 'tool-fs_read:0\u2192320', + ); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts index 8d9a7d19bfda1..26a27cdc117db 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts @@ -62,6 +62,7 @@ suite('formatEventDetail', () => { model: 'gpt-4o', inputTokens: 100, outputTokens: 50, + cachedTokens: 80, totalTokens: 150, durationInMillis: 320, }; @@ -69,6 +70,7 @@ suite('formatEventDetail', () => { assert.ok(result.includes('gpt-4o')); assert.ok(result.includes('100')); assert.ok(result.includes('50')); + assert.ok(result.includes('80')); assert.ok(result.includes('150')); assert.ok(result.includes('320')); }); diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index 0d14ef4504fcb..f1a4f480ff17e 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -153,6 +153,11 @@ declare module 'vscode' { */ timeToFirstTokenInMillis?: number; + /** + * The unique request id assigned by the model provider for this turn. + */ + requestId?: string; + /** * The maximum number of prompt/input tokens allowed for this request. */ @@ -168,6 +173,14 @@ declare module 'vscode' { */ requestName?: string; + /** + * Cache-relevant request options as a JSON-stringified blob (e.g. + * `tool_choice`, `reasoning_effort`, `thinking`, `response_format`). + * When this differs between two requests, the prompt cache is + * invalidated even if the message array is byte-identical. + */ + requestOptions?: string; + /** * The outcome status of the model turn (e.g., "success", "failure", "canceled"). */ @@ -546,6 +559,11 @@ declare module 'vscode' { */ timeToFirstTokenInMillis?: number; + /** + * The unique request id assigned by the model provider for this turn. + */ + requestId?: string; + /** * The maximum number of prompt/input tokens allowed for this request. */ @@ -576,6 +594,14 @@ declare module 'vscode' { */ totalTokens?: number; + /** + * Cache-relevant request options as a JSON-stringified blob (e.g. + * `tool_choice`, `reasoning_effort`, `thinking`, `response_format`). + * When this differs between two requests, the prompt cache is + * invalidated even if the message array is byte-identical. + */ + requestOptions?: string; + /** * An error message, if the model turn failed. */ From 1309e8018c2bef434322fbbc0f5950a6e826750b Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 1 May 2026 12:03:04 -0400 Subject: [PATCH 07/13] themes: improve quick pick focus contrast in 2026 themes (#313740) themes: improve quick pick focus contrast in 2026 themes (#307581) Update the 2026 Light and 2026 Dark quick input focused row colors to meet non-text contrast requirements against the quick input background. Also switch the focused row foreground and icon foreground to white so the stronger focus background remains legible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/theme-defaults/themes/2026-dark.json | 6 +++--- extensions/theme-defaults/themes/2026-light.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index c60093dbfaecb..0c35378cfa3f0 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -228,9 +228,9 @@ "pickerGroup.foreground": "#bfbfbf", "quickInput.background": "#202122", "quickInput.foreground": "#bfbfbf", - "quickInputList.focusBackground": "#3994BC26", - "quickInputList.focusForeground": "#bfbfbf", - "quickInputList.focusIconForeground": "#bfbfbf", + "quickInputList.focusBackground": "#297AA0", + "quickInputList.focusForeground": "#FFFFFF", + "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.hoverBackground": "#262728", "terminal.selectionBackground": "#3994BC33", "terminal.background": "#191A1B", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 95409bc8e65f3..474276171b770 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -232,9 +232,9 @@ "pickerGroup.foreground": "#202020", "quickInput.background": "#FAFAFD", "quickInput.foreground": "#202020", - "quickInputList.focusBackground": "#0069CC1A", - "quickInputList.focusForeground": "#202020", - "quickInputList.focusIconForeground": "#202020", + "quickInputList.focusBackground": "#0069CC", + "quickInputList.focusForeground": "#FFFFFF", + "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.hoverBackground": "#EDF0F5", "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", From f2725a346134adab8532153061c22aec58f727df Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 1 May 2026 09:29:42 -0700 Subject: [PATCH 08/13] Fix custom editor field documentation This should be documented, not deprecated :) --- src/vs/workbench/contrib/customEditor/common/extensionPoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts index 9187a0b2688f0..1e029d8945c22 100644 --- a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts +++ b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts @@ -60,7 +60,7 @@ const customEditorsContributionSchema = { }, [Fields.priority]: { type: 'string', - markdownDeprecationMessage: nls.localize('contributes.priority', 'Controls if the custom editor is enabled automatically when the user opens a file. This may be overridden by users using the `workbench.editorAssociations` setting.'), + markdownDescription: nls.localize('contributes.priority', 'Controls if the custom editor is enabled automatically when the user opens a file. This may be overridden by users using the `workbench.editorAssociations` setting.'), enum: [ CustomEditorPriority.default, CustomEditorPriority.option, From 92c7f27d4cda1fea615cc8e7fe44bf6e0602e3b2 Mon Sep 17 00:00:00 2001 From: Andrei Li Date: Fri, 1 May 2026 12:40:13 -0400 Subject: [PATCH 09/13] feat(plugins): allow component paths within repository boundary --- .../agentPlugins/common/pluginParsers.ts | 17 +++++++++------- .../test/common/pluginParsers.test.ts | 20 +++++++++++++++++++ .../common/plugins/agentPluginServiceImpl.ts | 11 +++++++--- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 3c8407d330784..1d6d8b4448d98 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -187,16 +187,18 @@ export function parseComponentPathConfig(raw: unknown): IComponentPathConfig { /** * Resolves the directories to scan for a given component type, combining * the default directory with any custom paths from the manifest config. - * Paths that resolve outside the plugin root are silently ignored. + * Paths that resolve outside the boundary are silently ignored. + * @param boundaryUri The outermost directory that resolved paths must stay within. Defaults to {@link pluginUri}. */ -export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig): readonly URI[] { +export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig, boundaryUri?: URI): readonly URI[] { + const boundary = (boundaryUri && isEqualOrParent(pluginUri, boundaryUri)) ? boundaryUri : pluginUri; const dirs: URI[] = []; if (!config.exclusive) { dirs.push(joinPath(pluginUri, defaultDir)); } for (const p of config.paths) { const resolved = normalizePath(joinPath(pluginUri, p)); - if (isEqualOrParent(resolved, pluginUri)) { + if (isEqualOrParent(resolved, boundary)) { dirs.push(resolved); } } @@ -811,6 +813,7 @@ export async function parsePlugin( fileService: IFileService, workspaceRoot: URI | undefined, userHome: string, + boundaryUri?: URI, ): Promise { const formatConfig = await detectPluginFormat(pluginUri, fileService); @@ -819,10 +822,10 @@ export async function parsePlugin( const manifest = (manifestJson && typeof manifestJson === 'object') ? manifestJson as Record : undefined; // Resolve component directories from manifest - const hookDirs = resolveComponentDirs(pluginUri, formatConfig.hookConfigPath, parseComponentPathConfig(manifest?.['hooks'])); - const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers'])); - const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills'])); - const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents'])); + const hookDirs = resolveComponentDirs(pluginUri, formatConfig.hookConfigPath, parseComponentPathConfig(manifest?.['hooks']), boundaryUri); + const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers']), boundaryUri); + const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills']), boundaryUri); + const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents']), boundaryUri); // Handle embedded MCP servers in manifest let embeddedMcp: IMcpServerDefinition[] = []; diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index afc3121f922f1..9d9a69923ed63 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -103,6 +103,26 @@ suite('pluginParsers', () => { // Should only have the default dir, the traversal path is rejected assert.strictEqual(dirs.length, 1); }); + + test('allows paths that escape plugin root but stay within boundaryUri', () => { + const boundaryUri = URI.file('/workspace'); + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['../shared-skills'], exclusive: false }, boundaryUri); + assert.strictEqual(dirs.length, 2); + assert.ok(dirs[1].path.endsWith('/shared-skills')); + }); + + test('rejects paths that escape boundaryUri', () => { + const boundaryUri = URI.file('/workspace'); + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['../../outside'], exclusive: false }, boundaryUri); + assert.strictEqual(dirs.length, 1); + }); + + test('falls back to pluginUri when boundaryUri is not an ancestor of pluginUri', () => { + const boundaryUri = URI.file('/unrelated/directory'); + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['custom'], exclusive: false }, boundaryUri); + assert.strictEqual(dirs.length, 2); + assert.ok(dirs[1].path.endsWith('/custom')); + }); }); // ---- normalizeMcpServerConfiguration -------------------------------- diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 3c3af139a2a3b..23fb44746437e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -147,6 +147,8 @@ type PluginEntry = IAgentPlugin; interface IPluginSource { readonly uri: URI; readonly fromMarketplace: IMarketplacePlugin | undefined; + /** Repository root that serves as the boundary for component path resolution. */ + readonly repositoryUri?: URI; /** Called when remove is invoked on the plugin */ remove(): void; } @@ -203,7 +205,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements if (!seenPluginUris.has(key)) { seenPluginUris.add(key); const format = await detectPluginFormat(source.uri, this._fileService); - plugins.push(this._toPlugin(source.uri, format, source.fromMarketplace, () => source.remove())); + plugins.push(this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, () => source.remove())); } } @@ -222,7 +224,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, removeCallback: () => void): IAgentPlugin { + private _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: () => void): IAgentPlugin { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -258,7 +260,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } const paths = parseComponentPathConfig(section); - const dirs = resolveComponentDirs(uri, defaultPath, paths); + const dirs = resolveComponentDirs(uri, defaultPath, paths, repositoryUri); for (const d of dirs) { const watcher = this._fileService.createWatcher(d, { recursive: false, excludes: [] }); reader.store.add(watcher); @@ -632,9 +634,12 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover continue; } + const repositoryUri = this._pluginRepositoryService.getRepositoryUri(entry.plugin.marketplaceReference, entry.plugin.marketplaceType); + sources.push({ uri: stat.resource, fromMarketplace: entry.plugin, + repositoryUri, remove: () => { this._enablementModel.remove(stat.resource.toString()); this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); From 086bc2fe8d68fcf7c84cb4d6c833882128839d28 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 09:53:19 -0700 Subject: [PATCH 10/13] Fix unwanted skill truncation (#313749) --- .../chat/common/promptSyntax/computeAutomaticInstructions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 6d36c65c7f50e..4ff46168a6864 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -475,7 +475,7 @@ export class ComputeAutomaticInstructions { skillEntry.push(`${filePath(skill.uri)}`); skillEntry.push(``); const entryLength = skillEntry.join('\n').length + 1; // +1 for joining newline - if (skillCharCount + entryLength > SKILL_DESCRIPTION_CHAR_BUDGET) { + if (skillTool && skillCharCount + entryLength > SKILL_DESCRIPTION_CHAR_BUDGET) { truncatedAtIndex = i; break; } From 5c359be9f81aee81bda8c03b47e4806ecfebf267 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 1 May 2026 10:05:34 -0700 Subject: [PATCH 11/13] Evict idle restored agent host sessions (#313275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Evict idle restored agent host sessions Adds reference-counted resource subscribers to AgentService so the agent-host can drop session state for restored sessions that nobody is viewing. Server side (src/vs/platform/agentHost): - AgentService: new addSubscriber/removeSubscriber + per-resource refcount via _resourceSubscribers; on last release, run _maybeEvictIdleRestoredSession which drops state when the session is restored AND idle. - AgentHostStateManager: new _restoredSessions set + isRestoredAndIdle. - protocolServerHandler: wire subscribe/unsubscribe RPCs to the new refcount, and drain a client's subscriptions in transport.onClose. Renderer side (src/vs/sessions/contrib/agentHost): - baseAgentHostSessionsProvider: 30 s idle timer (_keepSessionStateAlive) before tearing down the per-session subscription, using the existing IAgentConnection refcount. Tests: agentService, agentHostStateManager, protocolServerHandler, localAgentHostSessionsProvider — all updated. (Written by Copilot) * Drop restored-vs-created distinction; evict any idle session The previous implementation gated eviction behind an _restoredSessions set, on the theory that in-process sessions might not yet be persisted to the agent SDK. In practice, by the time AgentService.createSession returns, provider.createSession has resolved — meaning the backend agent owns the session and listSessions() will return it. So the gate was guarding against a scenario that doesn't happen. Remove the set, the predicate, and its tests. Eviction now keys solely on the active-turn veto, which is the only correctness-critical guard. The next subscribe rehydrates via the existing restoreSession path. (Written by Copilot) * Wire renderer subscribe through addSubscriber/removeSubscriber Drop IAgentService.unsubscribe. It was a no-op on the agent host side, which meant the electron renderer's subscriptions never decremented the per-resource refcount — so the idle-session eviction added in this PR only fired for the CLI/remote path. The renderer now pairs every subscribe with addSubscriber and routes the AgentSubscriptionManager unsubscribe callback to removeSubscriber. The CLI/remote path is unchanged: protocolServerHandler still maps the 'unsubscribe' wire notification to removeSubscriber. (Written by Copilot) * Fold subscriber registration into IAgentService.subscribe The subscribe/addSubscriber pairing was a footgun: callers had to remember to call both, and forgetting addSubscriber meant the per-resource refcount never tracked them, so idle eviction would never fire. Now subscribe(resource, clientId) registers the subscriber as part of the call. Also rename removeSubscriber → unsubscribe so the cleanup half of the pair has the obvious counterpart name (the previous unsubscribe was a no-op and was removed earlier in this PR). addSubscriber stays on the interface for the JSON-RPC handshake fast path (initialize / reconnect serve snapshots out of the in-memory state cache without going through the async subscribe()), with docs narrowing it to that use case. (Written by Copilot) * fixes Co-authored-by: Copilot * Reconnect: rehydrate evicted state via subscribe() When all clients disconnect, _maybeEvictIdleSession drops the cached state. The reconnect path was calling getSnapshot() to re-attach prior subscriptions, which silently dropped any subscription whose state had been evicted — the client would think it had reconnected, but the server held no subscription and no state for that resource. Have _handleReconnect call IAgentService.subscribe(uri, clientId) per prior subscription instead. subscribe() both registers the subscriber and restores state if missing, which is exactly what reconnect needs. Per-subscription failures are logged and skipped so one bad URI doesn't fail the whole reconnect. Splits the handler into a sync part (installs the client object so any in-flight messages can find it) plus an async response promise. Updates the existing two reconnect tests to await the response, and adds a new test that asserts reconnect re-hydrates state evicted while the client was disconnected. (Written by Copilot) --------- Co-authored-by: Copilot --- .../platform/agentHost/common/agentService.ts | 24 ++- .../electron-browser/agentHostService.ts | 8 +- .../platform/agentHost/node/agentService.ts | 153 ++++++++++++++---- .../agentHost/node/protocolServerHandler.ts | 81 +++++++--- .../agentHost/test/node/agentService.test.ts | 111 ++++++++++++- .../test/node/protocolServerHandler.test.ts | 60 ++++++- .../browser/baseAgentHostSessionsProvider.ts | 65 +++++++- .../localAgentHostSessionsProvider.test.ts | 30 ++++ 8 files changed, 450 insertions(+), 82 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index ed2e160ddc765..0fa89cecd9dab 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -543,12 +543,28 @@ export interface IAgentService { /** * Subscribe to state at the given URI. Returns a snapshot of the current * state and the serverSeq at snapshot time. Subsequent actions for this - * resource arrive via {@link onDidAction}. + * resource arrive via {@link onDidAction}. Registers `clientId` against + * the resource so the server-side refcount knows who is watching, so the + * caller does not need to invoke {@link addSubscriber} separately. Pair + * with {@link unsubscribe} when the subscription is released. */ - subscribe(resource: URI): Promise; + subscribe(resource: URI, clientId: string): Promise; - /** Unsubscribe from state updates for the given URI. */ - unsubscribe(resource: URI): void; + /** + * Counterpart to {@link subscribe}. Drops `clientId` from the refcount + * for `resource`; when the last subscriber is removed, idle session state + * for `resource` may be evicted from the server. + */ + unsubscribe(resource: URI, clientId: string): void; + + /** + * Register `clientId` against `resource` without going through + * {@link subscribe}. Only needed by callers that hand out snapshots + * synchronously (e.g. the JSON-RPC handshake serving `initialSubscriptions` + * out of the in-memory state cache); regular subscribers should call + * {@link subscribe} instead. Counterpart cleanup is {@link unsubscribe}. + */ + addSubscriber(resource: URI, clientId: string): void; /** * Fires when the server applies an action to subscribable state. diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index fc7504a280132..d426f2b2c0d2c 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -164,11 +164,11 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { shutdown(): Promise { return this._proxy.shutdown(); } - subscribe(resource: URI): Promise { - return this._proxy.subscribe(resource); + private subscribe(resource: URI): Promise { + return this._proxy.subscribe(resource, this.clientId); } - unsubscribe(resource: URI): void { - this._proxy.unsubscribe(resource); + private unsubscribe(resource: URI): void { + this._proxy.unsubscribe(resource, this.clientId); } dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 58fe04319768f..6a7dc566bb6fd 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7,6 +7,7 @@ import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../base/common/map.js'; import { equals as objectEquals } from '../../../base/common/objects.js'; import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; @@ -71,6 +72,17 @@ export class AgentService extends Disposable implements IAgentService { private readonly _terminalManager: AgentHostTerminalManager; private readonly _configurationService: IAgentConfigurationService; + /** + * Authoritative server-side per-resource subscription refcount, keyed by + * resource URI string and valued by the set of subscribed protocol + * client IDs. Populated by {@link subscribe} (or {@link addSubscriber} + * for handshake fast-paths) and drained by {@link unsubscribe}. When a + * resource's set becomes empty, the resource is dropped from the map and + * {@link _maybeEvictIdleSession} is invoked to release any cached state + * for it. + */ + private readonly _resourceSubscribers = new ResourceMap>(); + /** Exposes the terminal manager for use by agent providers. */ get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; } @@ -417,50 +429,125 @@ export class AgentService extends Disposable implements IAgentService { this._terminalManager.disposeTerminal(terminal.toString()); } - async subscribe(resource: URI): Promise { + 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. + this.addSubscriber(resource, clientId); + try { + // Check for terminal state + const terminalState = this._terminalManager.getTerminalState(resourceStr); + if (terminalState) { + return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq }; + } - // Check for terminal state - const terminalState = this._terminalManager.getTerminalState(resourceStr); - if (terminalState) { - return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq }; - } + let snapshot = this._stateManager.getSnapshot(resourceStr); + if (!snapshot) { + // Try subagent restore before regular session restore + const parsed = parseSubagentSessionUri(resourceStr); + if (parsed) { + await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId); + } else { + await this.restoreSession(resource); + } + snapshot = this._stateManager.getSnapshot(resourceStr); + } + if (!snapshot) { + throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`); + } - let snapshot = this._stateManager.getSnapshot(resourceStr); - if (!snapshot) { - // Try subagent restore before regular session restore - const parsed = parseSubagentSessionUri(resourceStr); - if (parsed) { - await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId); - } else { - await this.restoreSession(resource); + // Ensure git state has been computed for this session. When the snapshot + // already existed (e.g. seeded by list query, or restored earlier), the + // restore path that normally calls `_attachGitState` is skipped — so + // trigger it lazily here for the first subscriber. `_attachGitState` + // is async and updates `_meta.git` once ready, which clients see via + // the normal state-update stream. + const sessionState = this._stateManager.getSessionState(resourceStr); + if (sessionState && readSessionGitState(sessionState._meta) === undefined) { + const wd = sessionState.summary?.workingDirectory; + this._attachGitState(resource, wd ? URI.parse(wd) : undefined); } - snapshot = this._stateManager.getSnapshot(resourceStr); - } - if (!snapshot) { - throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`); + + return snapshot; + } catch (err) { + this.unsubscribe(resource, clientId); + throw err; } + } - // Ensure git state has been computed for this session. When the snapshot - // already existed (e.g. seeded by list query, or restored earlier), the - // restore path that normally calls `_attachGitState` is skipped — so - // trigger it lazily here for the first subscriber. `_attachGitState` - // is async and updates `_meta.git` once ready, which clients see via - // the normal state-update stream. - const sessionState = this._stateManager.getSessionState(resourceStr); - if (sessionState && readSessionGitState(sessionState._meta) === undefined) { - const wd = sessionState.summary?.workingDirectory; - this._attachGitState(resource, wd ? URI.parse(wd) : undefined); + addSubscriber(resource: URI, clientId: string): void { + let set = this._resourceSubscribers.get(resource); + if (!set) { + set = new Set(); + this._resourceSubscribers.set(resource, set); } + set.add(clientId); + } - return snapshot; + unsubscribe(resource: URI, clientId: string): void { + const set = this._resourceSubscribers.get(resource); + if (!set) { + return; + } + set.delete(clientId); + if (set.size > 0) { + return; + } + this._resourceSubscribers.delete(resource); + this._maybeEvictIdleSession(resource); } - unsubscribe(resource: URI): void { - this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`); - // Server-side tracking of per-client subscriptions will be added - // in Phase 4 (multi-client). For now this is a no-op. + /** + * If `resource` names an idle session and no client is still subscribed to + * it (or, for a subagent URI, no sibling subagent under the same parent is + * still subscribed), drop its cached state from the state manager. Subagent + * URIs evict the parent session entry; the parent owns the materialized + * turn tree that backs every subagent view. The next subscribe will + * rehydrate the session via {@link restoreSession}. + */ + private _maybeEvictIdleSession(resource: URI): void { + const key = resource.toString(); + if (this._resourceSubscribers.has(resource)) { + return; + } + const parsed = parseSubagentSessionUri(key); + let evictionTarget: string; + if (parsed) { + evictionTarget = parsed.parentSession; + if (this._resourceSubscribers.has(URI.parse(evictionTarget))) { + return; + } + const parentPrefix = parsed.parentSession + '/subagent/'; + for (const subscribedUri of this._resourceSubscribers.keys()) { + if (subscribedUri.toString().startsWith(parentPrefix)) { + return; + } + } + } else { + evictionTarget = key; + const subagentPrefix = key + '/subagent/'; + for (const subscribedUri of this._resourceSubscribers.keys()) { + if (subscribedUri.toString().startsWith(subagentPrefix)) { + return; + } + } + } + const targetState = this._stateManager.getSessionState(evictionTarget); + if (!targetState || targetState.activeTurn !== undefined) { + return; + } + this._logService.trace(`[AgentService] Evicting idle session: ${evictionTarget} (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 = evictionTarget + '/subagent/'; + for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { + this._stateManager.removeSession(cachedKey); + } + this._stateManager.removeSession(evictionTarget); } dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 5c56631630029..0534484861eb5 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -158,13 +158,19 @@ export class ProtocolServerHandler extends Disposable { return; } if (!client && msg.method === 'reconnect') { + let responsePromise: Promise; try { const result = this._handleReconnect(msg.params, transport, disposables); client = result.client; - transport.send(jsonRpcSuccess(msg.id, result.response)); + responsePromise = result.responsePromise; } catch (err) { transport.send(jsonRpcErrorFrom(msg.id, err)); + return; } + responsePromise.then( + response => transport.send(jsonRpcSuccess(msg.id, response)), + err => transport.send(jsonRpcErrorFrom(msg.id, err)), + ); return; } @@ -178,7 +184,10 @@ export class ProtocolServerHandler extends Disposable { switch (msg.method) { case 'unsubscribe': if (client) { - client.subscriptions.delete(msg.params.resource); + const resource = msg.params.resource; + if (client.subscriptions.delete(resource)) { + this._agentService.unsubscribe(URI.parse(resource), client.clientId); + } } break; case 'dispatchAction': @@ -207,6 +216,13 @@ export class ProtocolServerHandler extends Disposable { disposables.add(transport.onClose(() => { if (client && this._clients.get(client.clientId) === client) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`); + // Treat disconnect as an implicit unsubscribe of every resource the + // client held, so the server-side refcount can drop to zero and any + // idle restored session state can be evicted. + for (const resource of client.subscriptions) { + this._agentService.unsubscribe(URI.parse(resource), client.clientId); + } + client.subscriptions.clear(); this._clients.delete(client.clientId); this._rejectPendingReverseRequests(client.clientId); this._handleClientDisconnected(client.clientId); @@ -259,8 +275,10 @@ export class ProtocolServerHandler extends Disposable { const snapshot = this._stateManager.getSnapshot(uri); if (snapshot) { snapshots.push(snapshot); - client.subscriptions.add(uri.toString()); - this._clearClientToolCallDisconnectTimeout(params.clientId, uri.toString()); + const key = uri.toString(); + client.subscriptions.add(key); + this._agentService.addSubscriber(URI.parse(key), client.clientId); + this._clearClientToolCallDisconnectTimeout(params.clientId, key); } } } @@ -280,9 +298,12 @@ export class ProtocolServerHandler extends Disposable { params: ReconnectParams, transport: IProtocolTransport, disposables: DisposableStore, - ): { client: IConnectedClient; response: unknown } { + ): { client: IConnectedClient; responsePromise: Promise } { this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`); + // Synchronously install the client so messages arriving on this transport + // while we restore subscriptions can find a valid client object. The + // reconnect response is only sent once `responsePromise` resolves below. const client: IConnectedClient = { clientId: params.clientId, protocolVersion: PROTOCOL_VERSION, @@ -296,12 +317,38 @@ export class ProtocolServerHandler extends Disposable { const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; const canReplay = params.lastSeenServerSeq >= oldestBuffered; + const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay); + return { client, responsePromise }; + } + + /** + * Re-establish each of the client's prior subscriptions on the server side. + * Uses {@link IAgentService.subscribe} (rather than a bare `addSubscriber` + * + `getSnapshot`) so any session state that was evicted while the client + * was disconnected is restored. Returns the appropriate reconnect response + * payload — `replay` actions when the client's last-seen seq is still in + * the buffer, otherwise fresh `snapshot`s. + */ + private async _restoreReconnectSubscriptions( + client: IConnectedClient, + params: ReconnectParams, + canReplay: boolean, + ): Promise { + const snapshots = await Promise.all(params.subscriptions.map(async sub => { + const key = sub.toString(); + try { + const snapshot = await this._agentService.subscribe(URI.parse(key), client.clientId); + client.subscriptions.add(key); + this._clearClientToolCallDisconnectTimeout(client.clientId, key); + return snapshot; + } catch (err) { + this._logService.warn(`[ProtocolServer] Reconnect: failed to restore subscription ${key}: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + })); + if (canReplay) { const actions: ActionEnvelope[] = []; - for (const sub of params.subscriptions) { - client.subscriptions.add(sub.toString()); - this._clearClientToolCallDisconnectTimeout(params.clientId, sub.toString()); - } for (const envelope of this._replayBuffer) { if (envelope.serverSeq > params.lastSeenServerSeq) { if (this._isRelevantToClient(client, envelope)) { @@ -309,19 +356,9 @@ export class ProtocolServerHandler extends Disposable { } } } - return { client, response: { type: 'replay', actions } }; - } else { - const snapshots: IStateSnapshot[] = []; - for (const sub of params.subscriptions) { - const snapshot = this._stateManager.getSnapshot(sub); - if (snapshot) { - snapshots.push(snapshot); - client.subscriptions.add(sub); - this._clearClientToolCallDisconnectTimeout(params.clientId, sub); - } - } - return { client, response: { type: 'snapshot', snapshots } }; + return { type: 'replay', actions }; } + return { type: 'snapshot', snapshots: snapshots.filter((s): s is IStateSnapshot => s !== undefined) }; } private _handleClientDisconnected(clientId: string): void { @@ -428,7 +465,7 @@ export class ProtocolServerHandler extends Disposable { private readonly _requestHandlers: RequestHandlerMap = { subscribe: async (client, params) => { try { - const snapshot = await this._agentService.subscribe(URI.parse(params.resource)); + const snapshot = await this._agentService.subscribe(URI.parse(params.resource), client.clientId); client.subscriptions.add(params.resource); this._clearClientToolCallDisconnectTimeout(client.clientId, params.resource); return { snapshot }; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 248a38a89f3e5..0a6edebac672d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -441,7 +441,7 @@ suite('AgentService (node dispatcher)', () => { localService.stateManager.setSessionMeta(session.toString(), undefined); calls.length = 0; - await localService.subscribe(session); + await localService.subscribe(session, 'client-1'); for (let i = 0; i < 5; i++) { await Promise.resolve(); } @@ -702,7 +702,7 @@ suite('AgentService (node dispatcher)', () => { // Subscribing to the child session should restore it with inner tool calls const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); - const snapshot = await service.subscribe(URI.parse(childSessionUri)); + const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); const childState = service.stateManager.getSessionState(childSessionUri); assert.ok(snapshot?.state, 'Child session snapshot should exist'); assert.ok(childState, 'Child session state should exist'); @@ -748,7 +748,7 @@ suite('AgentService (node dispatcher)', () => { // Subscribe to the child subagent session and verify inner tools const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId); - const snapshot = await service.subscribe(URI.parse(childSessionUri)); + const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); assert.ok(snapshot?.state, 'Child session snapshot should exist'); const childState = service.stateManager.getSessionState(childSessionUri); assert.ok(childState, 'Child session state should exist'); @@ -762,7 +762,110 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- session config persistence ------------------------------------- + // ---- subscriber refcount + idle eviction ---------------------------- + + suite('subscriber refcount eviction', () => { + + test('an idle session created in this lifetime is evicted when subscribers drop', async () => { + service.registerProvider(copilotAgent); + const sessionResource = await service.createSession({ provider: 'copilot' }); + + service.addSubscriber(sessionResource, 'client-1'); + service.unsubscribe(sessionResource, 'client-1'); + + assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'idle created session should be evicted; next subscribe will rehydrate from the agent'); + }); + + test('a session with an active turn is NOT evicted when its last subscriber drops', async () => { + service.registerProvider(copilotAgent); + const sessionResource = await service.createSession({ provider: 'copilot' }); + + service.addSubscriber(sessionResource, 'client-1'); + // Simulate an in-flight turn — eviction must skip this session even + // when the refcount reaches zero, otherwise we'd drop live state + // mid-response. + service.dispatchAction( + { type: ActionType.SessionTurnStarted, session: sessionResource.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, + 'client-1', 1, + ); + + service.unsubscribe(sessionResource, 'client-1'); + + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); + }); + + test('a restored idle session is evicted when its last subscriber drops', async () => { + service.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.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(sessionResource); + service.addSubscriber(sessionResource, 'client-1'); + + service.unsubscribe(sessionResource, 'client-1'); + + assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted'); + }); + + test('multiple subscribers keep a restored session alive until all drop', async () => { + service.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.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(sessionResource); + service.addSubscriber(sessionResource, 'client-1'); + service.addSubscriber(sessionResource, 'client-2'); + + service.unsubscribe(sessionResource, 'client-1'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2'); + + service.unsubscribe(sessionResource, 'client-2'); + assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber'); + }); + + test('subagent subscriber pins the parent session against eviction', async () => { + service.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, + { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating', toolKind: 'subagent' as const, subagentDescription: 'Find files', subagentAgentName: 'explore' }, + { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }, + { type: 'tool_start', session, toolCallId: 'tc-inner', toolName: 'bash', displayName: 'Bash', invocationMessage: 'ls', parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-inner', result: { success: true, pastTenseMessage: 'ran', content: [{ type: ToolResultContentType.Text, text: 'a' }] }, parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'done', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done', toolRequests: [] }, + ]; + await service.restoreSession(sessionResource); + const childUri = URI.parse(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')); + await service.subscribe(childUri, 'client-child'); + + service.addSubscriber(sessionResource, 'client-parent'); + + // Parent drops — child still subscribed, parent must not be evicted + service.unsubscribe(sessionResource, 'client-parent'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); + assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present'); + + // Child drops — both can now be evicted + service.unsubscribe(childUri, 'client-child'); + assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); + assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); + }); + }); suite('session config persistence', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index b0807afae93ea..9f30d5f6e372d 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -112,14 +112,15 @@ class MockAgentService implements IAgentService { async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async listSessions(): Promise { return this.listedSessions; } - async subscribe(resource: URI): Promise { + async subscribe(resource: URI, _clientId: string): Promise { const snapshot = this._stateManager.getSnapshot(resource.toString()); if (!snapshot) { throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`); } return snapshot; } - unsubscribe(_resource: URI): void { } + addSubscriber(_resource: URI, _clientId: string): void { } + unsubscribe(_resource: URI, _clientId: string): void { } async shutdown(): Promise { } async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } @@ -434,7 +435,7 @@ suite('ProtocolServerHandler', () => { }); }); - test('reconnect replays missed actions', () => { + test('reconnect replays missed actions', async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); @@ -448,14 +449,14 @@ suite('ProtocolServerHandler', () => { const transport2 = new MockProtocolTransport(); server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); transport2.simulateMessage(request(1, 'reconnect', { clientId: 'client-r', lastSeenServerSeq: initSeq, subscriptions: [sessionUri], })); - const reconnectResp = findResponse(transport2.sent, 1); - assert.ok(reconnectResp, 'should have sent reconnect response'); + const reconnectResp = await reconnectRespPromise; const result = (reconnectResp as { result: ReconnectResult }).result; assert.strictEqual(result.type, 'replay'); if (result.type === 'replay') { @@ -463,7 +464,7 @@ suite('ProtocolServerHandler', () => { } }); - test('reconnect sends fresh snapshots when gap too large', () => { + test('reconnect sends fresh snapshots when gap too large', async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); @@ -476,14 +477,14 @@ suite('ProtocolServerHandler', () => { const transport2 = new MockProtocolTransport(); server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); transport2.simulateMessage(request(1, 'reconnect', { clientId: 'client-g', lastSeenServerSeq: 0, subscriptions: [sessionUri], })); - const reconnectResp = findResponse(transport2.sent, 1); - assert.ok(reconnectResp, 'should have sent reconnect response'); + const reconnectResp = await reconnectRespPromise; const result = (reconnectResp as { result: ReconnectResult }).result; assert.strictEqual(result.type, 'snapshot'); if (result.type === 'snapshot') { @@ -491,6 +492,49 @@ suite('ProtocolServerHandler', () => { } }); + test('reconnect rehydrates server-side state that was evicted while disconnected', async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + // MockAgentService.subscribe normally just returns the existing snapshot. + // Override it so a missing session is restored on subscribe — this is the + // behavior the real AgentService provides and that reconnect now relies on. + const subscribeCalls: string[] = []; + agentService.subscribe = async (resource, _clientId) => { + subscribeCalls.push(resource.toString()); + let snapshot = stateManager.getSnapshot(resource.toString()); + if (!snapshot) { + stateManager.restoreSession(makeSessionSummary(), []); + snapshot = stateManager.getSnapshot(resource.toString())!; + } + return snapshot; + }; + + const transport1 = connectClient('client-e', [sessionUri]); + const initResp = findResponse(transport1.sent, 1); + const initSeq = (initResp as { result: InitializeResult }).result.serverSeq; + transport1.simulateClose(); + + // Simulate the AgentService evicting the idle session while the client + // was disconnected (this is what `_maybeEvictIdleSession` does in the + // real service). + stateManager.removeSession(sessionUri); + assert.strictEqual(stateManager.getSnapshot(sessionUri), undefined, 'precondition: state evicted'); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-e', + lastSeenServerSeq: initSeq, + subscriptions: [sessionUri], + })); + + await reconnectRespPromise; + assert.deepStrictEqual(subscribeCalls, [sessionUri], 'reconnect should call subscribe to restore evicted state'); + assert.ok(stateManager.getSnapshot(sessionUri), 'state should have been re-hydrated by reconnect'); + }); + test('client disconnect cleans up', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 7b7992881934c..4ddf54c2896e1 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { raceTimeout } from '../../../../base/common/async.js'; +import { raceTimeout, disposableTimeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { equals } from '../../../../base/common/objects.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -358,10 +358,22 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * window). The underlying wire subscription is reference-counted by * {@link IAgentConnection.getSubscription}, so when the session handler is * also subscribed (i.e. chat content is loaded) no extra wire subscribe is - * issued. Keyed by session ID. + * issued. Each entry is released after + * {@link SESSION_STATE_SUBSCRIPTION_IDLE_MS} of no calls into the keep-alive + * helper, so the server-side refcount can drop and any idle restored session + * state can be evicted on the agent host. Keyed by session ID. */ protected readonly _sessionStateSubscriptions = this._register(new DisposableMap()); + /** + * Idle-release timers paired with {@link _sessionStateSubscriptions}. Each + * call to {@link _keepSessionStateAlive} resets the timer for `sessionId`; + * when the timer fires, the subscription is disposed and the wire + * `unsubscribe` flows through {@link IAgentConnection.getSubscription}'s + * refcount to the agent host. + */ + private readonly _sessionStateIdleTimers = this._register(new DisposableMap()); + protected _cacheInitialized = false; constructor( @@ -499,8 +511,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (cached.resource.toString() === resource.toString()) { // Opening a session: subscribe to its AHP state so that // `_meta` (e.g. lazy git state computed by the agent host) - // flows into the cached adapter. - this._ensureSessionStateSubscription(cached.sessionId); + // flows into the cached adapter. The keep-alive helper resets + // an idle timer so the subscription is dropped once the session + // is no longer being touched, allowing the agent host to evict + // idle restored state. + this._keepSessionStateAlive(cached.sessionId); return cached; } } @@ -616,12 +631,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // New-session config wins (during pre-creation flow). Otherwise lazily // subscribe to the session's state so the running picker can seed its // schema/values from the AHP `SessionState.config` snapshot for sessions - // that weren't created in this window. + // that weren't created in this window. Each query bumps the idle timer + // so the subscription stays alive while the picker (or any other UI + // surface) is repeatedly reading the running config. const newSessionConfig = this._newSessionConfigs.get(sessionId); if (newSessionConfig) { return newSessionConfig; } - this._ensureSessionStateSubscription(sessionId); + this._keepSessionStateAlive(sessionId); return this._runningSessionConfigs.get(sessionId); } @@ -1074,6 +1091,39 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Lazy session-state subscription seeding ----------------------------- + /** + * Idle window before a lazily-created session-state subscription is + * released. Each call to {@link _keepSessionStateAlive} resets the timer. + * Long enough to absorb the open→config-picker churn while a session view + * is active; short enough that closed sessions release within a minute or + * so, allowing the agent host to evict their cached restored state. + */ + private static readonly SESSION_STATE_SUBSCRIPTION_IDLE_MS = 30_000; + + /** + * Bump the idle-release timer for `sessionId` and lazily create the + * underlying subscription if needed. Called from query paths + * ({@link getSessionByResource}, {@link getSessionConfig}) that depend on + * `_runningSessionConfigs` / `_meta` being in sync but cannot themselves + * own a subscription handle. + */ + private _keepSessionStateAlive(sessionId: string): void { + this._ensureSessionStateSubscription(sessionId); + if (!this._sessionStateSubscriptions.has(sessionId)) { + return; + } + this._sessionStateIdleTimers.set( + sessionId, + disposableTimeout( + () => { + this._sessionStateIdleTimers.deleteAndDispose(sessionId); + this._sessionStateSubscriptions.deleteAndDispose(sessionId); + }, + BaseAgentHostSessionsProvider.SESSION_STATE_SUBSCRIPTION_IDLE_MS, + ), + ); + } + /** * Lazily acquire a session-state subscription for `sessionId` so that * `_runningSessionConfigs` is seeded from the AHP `SessionState.config` @@ -1318,6 +1368,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (cached) { this._sessionCache.delete(rawId); this._runningSessionConfigs.delete(cached.sessionId); + this._sessionStateIdleTimers.deleteAndDispose(cached.sessionId); this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId); this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); } diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index eb643c787963e..bb77d64ae0300 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -948,6 +948,36 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr), 1); })); + test('session-state subscription auto-releases after the idle window', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('idle-1', { summary: 'Idle Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Idle Session'); + assert.ok(session); + + const sessionUriStr = AgentSession.uri('copilotcli', 'idle-1').toString(); + + // Initial access subscribes. + provider.getSessionConfig(session!.sessionId); + assert.strictEqual(agentHost.sessionSubscribeCounts.get(sessionUriStr), 1); + assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0); + + // Repeated access within the idle window does not re-subscribe. + await timeout(20_000); + provider.getSessionConfig(session!.sessionId); + assert.strictEqual(agentHost.sessionSubscribeCounts.get(sessionUriStr), 1, 'still one wire subscribe'); + assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0, 'no unsubscribe yet (timer reset)'); + + // Idle past the 30 s window — wire unsubscribe fires. + await timeout(31_000); + assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr), 1, 'wire unsubscribe after idle window'); + + // Re-access after release re-subscribes. + provider.getSessionConfig(session!.sessionId); + assert.strictEqual(agentHost.sessionSubscribeCounts.get(sessionUriStr), 2, 'fresh subscribe after release'); + })); + // ---- replaceSessionConfig ------- test('replaceSessionConfig only replaces sessionMutable, non-readOnly values and preserves everything else', () => runWithFakedTimers({ useFakeTimers: true }, async () => { From 345496c2faf2a6a57a5e1644ed77fc572db6b80e Mon Sep 17 00:00:00 2001 From: john lee <64lamei@gmail.com> Date: Sat, 2 May 2026 01:48:38 +0800 Subject: [PATCH 12/13] fix: enable text selection in elicitation dialog markdown content fix: enable text selection in elicitation dialog markdown content --- .../chatContentParts/media/chatQuestionCarousel.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 66535a8f2927d..79ed08b2398f7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -462,6 +462,13 @@ } } +/* user-select: text for elicitation dialog text areas */ +.interactive-session .chat-question-carousel-container .chat-question-detailed-message, +.interactive-session .chat-question-carousel-container .chat-question-description { + user-select: text; + -webkit-user-select: text; +} + /* carousel-level message (e.g. from MCP elicitation) */ .interactive-session .chat-question-carousel-container .chat-question-carousel-message { padding: 8px 16px 0; @@ -470,6 +477,8 @@ max-height: min(220px, 25vh); overflow-y: auto; overscroll-behavior: contain; + user-select: text; + -webkit-user-select: text; .rendered-markdown p { margin: 0; From 230faad3904b706e6a72e7652b32a580bc7a3a68 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 1 May 2026 11:04:49 -0700 Subject: [PATCH 13/13] Few chronicle updates (#312284) * Optin UX Co-authored-by: Copilot * setting rename and updates Co-authored-by: Copilot * cleanup Co-authored-by: Copilot * few updates Co-authored-by: Copilot * Feedback updates Co-authored-by: Copilot * policy doc * few updates Co-authored-by: Copilot * minor update * excludeRepo changes Co-authored-by: Copilot * update policy name Co-authored-by: Copilot * delete Co-authored-by: Copilot * status UI Co-authored-by: Copilot * delete and reindex updates Co-authored-by: Copilot * UX and status updates Co-authored-by: Copilot * policy update * feedback updates Co-authored-by: Copilot * telemetry update Co-authored-by: Copilot --------- Co-authored-by: Copilot --- build/lib/policies/policyData.jsonc | 45 +- extensions/copilot/package.json | 6 + extensions/copilot/package.nls.json | 1 + .../chronicle/common/eventTranslator.ts | 118 ++++- .../common/sessionIndexingPreference.ts | 18 +- .../common/sessionSyncStateService.ts | 34 ++ .../common/test/eventTranslator.spec.ts | 161 +++++- .../test/sessionIndexingPreference.spec.ts | 48 +- .../chronicle/node/cloudSessionApiClient.ts | 207 ++++++++ .../chronicle/node/cloudSessionIdStore.ts | 144 +++++ .../chronicle/node/sessionReindexer.ts | 213 ++++++++ .../node/test/cloudSessionIdStore.spec.ts | 177 +++++++ .../node/test/sessionReindexer.spec.ts | 230 +++++++- .../vscode-node/remoteSessionExporter.ts | 494 +++++++++++++++++- .../vscode-node/sessionSync.contribution.ts | 29 + .../vscode-node/sessionSyncStatus.ts | 115 ++++ .../extension/vscode-node/contributions.ts | 4 +- .../extension/intents/node/chronicleIntent.ts | 50 +- .../platform/chronicle/common/sessionStore.ts | 3 + .../platform/chronicle/node/sessionStore.ts | 16 + .../common/configurationService.ts | 5 - src/vs/base/common/defaultAccount.ts | 9 + .../agentSessions.contribution.ts | 3 +- .../agentSessions/agentSessionsActions.ts | 59 +++ .../contrib/chat/browser/chat.contribution.ts | 26 + .../browser/chatStatus/media/chatStatus.css | 1 + .../contrib/chat/common/constants.ts | 2 + .../accounts/browser/defaultAccount.ts | 5 +- .../browser/multiplexPolicyService.test.ts | 65 ++- 29 files changed, 2216 insertions(+), 72 deletions(-) create mode 100644 extensions/copilot/src/extension/chronicle/common/sessionSyncStateService.ts create mode 100644 extensions/copilot/src/extension/chronicle/node/cloudSessionIdStore.ts create mode 100644 extensions/copilot/src/extension/chronicle/node/test/cloudSessionIdStore.spec.ts create mode 100644 extensions/copilot/src/extension/chronicle/vscode-node/sessionSync.contribution.ts create mode 100644 extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 4c5d01a88a864..6a0749e712110 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,21 +38,6 @@ } ], "policies": [ - { - "key": "extensions.gallery.serviceUrl", - "name": "ExtensionGalleryServiceUrl", - "category": "Extensions", - "minimumVersion": "1.99", - "localization": { - "description": { - "key": "extensions.gallery.serviceUrl", - "value": "Configure the Marketplace service URL to connect to" - } - }, - "type": "string", - "default": "", - "included": false - }, { "key": "chat.mcp.gallery.serviceUrl", "name": "McpGalleryServiceUrl", @@ -83,6 +68,21 @@ "default": [], "included": false }, + { + "key": "extensions.gallery.serviceUrl", + "name": "ExtensionGalleryServiceUrl", + "category": "Extensions", + "minimumVersion": "1.99", + "localization": { + "description": { + "key": "extensions.gallery.serviceUrl", + "value": "Configure the Marketplace service URL to connect to" + } + }, + "type": "string", + "default": "", + "included": false + }, { "key": "extensions.allowed", "name": "AllowedExtensions", @@ -113,6 +113,21 @@ "default": false, "included": true }, + { + "key": "chat.sessionSync.enabled", + "name": "CopilotSessionSync", + "category": "InteractiveSession", + "minimumVersion": "1.119", + "localization": { + "description": { + "key": "chat.sessionSync.enabled.policy", + "value": "Enable session sync to GitHub.com for cross-device Copilot session history. When disabled by organization policy, session data is kept local only." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.tools.eligibleForAutoApproval", "name": "ChatToolsEligibleForAutoApproval", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 13aa9196254ca..0dcfc623a552c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2678,6 +2678,12 @@ "category": "Chat", "enablement": "config.github.copilot.chat.otel.dbSpanExporter.enabled" }, + { + "command": "github.copilot.sessionSync.deleteSessions", + "title": "%github.copilot.command.sessionSync.deleteSessions%", + "category": "Chat", + "enablement": "github.copilot.sessionSearch.enabled && config.chat.sessionSync.enabled" + }, { "command": "github.copilot.nes.captureExpected.start", "title": "Record Expected Edit (NES)", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 44adc44eb8c7b..abc75223e7547 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -171,6 +171,7 @@ "copilot.chronicle.description": "Session history tools and insights", "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", + "github.copilot.command.sessionSync.deleteSessions": "Delete Session Sync Data", "copilot.chronicle.reindex.description": "Rebuild the local session index from stored session logs. Add 'force' to re-process already indexed sessions.", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", diff --git a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts index a63023a0950fe..3b14244c2acda 100644 --- a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts +++ b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts @@ -6,6 +6,7 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../platform/otel/common/genAiAttributes'; import type { ICompletedSpanData } from '../../../platform/otel/common/otelService'; +import type { IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService'; import type { SessionEvent, WorkingDirectoryContext } from './cloudSessionTypes'; // ── Content size limits (bytes) ───────────────────────────────────────────────── @@ -192,6 +193,111 @@ export function makeShutdownEvent(state: SessionTranslationState): SessionEvent return makeEvent(state, 'session.shutdown', {}); } +// ── Debug log entry → cloud event translation ─────────────────────────────────── + +/** + * Translate a JSONL debug log entry into zero or more cloud SessionEvents. + * + * Used by the cloud reindex phase to upload historical sessions that were + * never live-synced. Mirrors the event types produced by {@link translateSpan} + * so the cloud sees a consistent format regardless of how events were captured. + * + * Mutates `state` to maintain parentId chaining across entries. + */ +export function translateDebugLogEntry( + entry: IDebugLogEntry, + sessionId: string, + state: SessionTranslationState, +): SessionEvent[] { + const events: SessionEvent[] = []; + const ts = new Date(entry.ts).toISOString(); + + switch (entry.type) { + case 'session_start': { + if (!state.started) { + state.started = true; + events.push(makeEventAt(state, ts, 'session.start', { + sessionId, + version: 1, + producer: 'vscode-copilot-chat', + copilotVersion: typeof entry.attrs.copilotVersion === 'string' ? entry.attrs.copilotVersion : '1.0.0', + startTime: ts, + context: { + cwd: typeof entry.attrs.cwd === 'string' ? entry.attrs.cwd : undefined, + repository: typeof entry.attrs.repository === 'string' ? entry.attrs.repository : undefined, + hostType: 'github', + branch: typeof entry.attrs.branch === 'string' ? entry.attrs.branch : undefined, + }, + })); + } + break; + } + + case 'user_message': + case 'turn_start': { + const content = typeof entry.attrs.content === 'string' + ? entry.attrs.content + : typeof entry.attrs.userRequest === 'string' + ? entry.attrs.userRequest + : undefined; + if (content) { + events.push(makeEventAt(state, ts, 'user.message', { + content: truncate(content, MAX_USER_MESSAGE_SIZE), + source: 'chat', + agentMode: 'interactive', + })); + } + break; + } + + case 'agent_response': { + const response = typeof entry.attrs.response === 'string' ? entry.attrs.response : undefined; + if (response) { + events.push(makeEventAt(state, ts, 'assistant.message', { + messageId: generateUuid(), + content: truncate(response, MAX_ASSISTANT_MESSAGE_SIZE), + })); + } + break; + } + + case 'tool_call': { + const toolName = entry.name; + if (toolName) { + const toolCallId = entry.spanId || generateUuid(); + const resultText = typeof entry.attrs.result === 'string' ? entry.attrs.result : undefined; + const success = entry.status === 'ok'; + const truncatedResult = resultText ? truncate(resultText, MAX_TOOL_RESULT_SIZE) : ''; + + events.push(makeEventAt(state, ts, 'tool.execution_complete', { + toolCallId, + toolName, + success, + result: success ? { + content: truncatedResult, + detailedContent: truncatedResult, + } : undefined, + error: !success ? { + message: truncatedResult || (typeof entry.attrs.error === 'string' ? entry.attrs.error : 'Tool execution failed'), + code: 'failure', + } : undefined, + })); + } + break; + } + } + + // Filter out oversized events + return events.filter(event => { + const size = estimateEventSize(event); + if (size > MAX_EVENT_SIZE) { + state.droppedCount++; + return false; + } + return true; + }); +} + // ── Internal helpers ──────────────────────────────────────────────────────────── function makeEvent( @@ -199,11 +305,21 @@ function makeEvent( type: string, data: Record, ephemeral?: boolean, +): SessionEvent { + return makeEventAt(state, new Date().toISOString(), type, data, ephemeral); +} + +function makeEventAt( + state: SessionTranslationState, + timestamp: string, + type: string, + data: Record, + ephemeral?: boolean, ): SessionEvent { const id = generateUuid(); const event: SessionEvent = { id, - timestamp: new Date().toISOString(), + timestamp, parentId: state.lastEventId, type, data, diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts index c7d3d2de9605e..a755e71490167 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import picomatch from 'picomatch'; /** @@ -16,6 +16,12 @@ export type SessionIndexingLevel = 'local' | 'user' | 'repo_and_user'; /** * Manages user preferences for session indexing via VS Code settings. + * + * Two settings control behavior: + * - `chat.localIndex.enabled` (ExP) — enables local + * SQLite tracking and /chronicle commands + * - `chat.sessionSync.enabled` (core setting with enterprise policy) — enables + * cloud upload */ export class SessionIndexingPreference { @@ -24,7 +30,7 @@ export class SessionIndexingPreference { ) { } /** - * Get the effective storage level for a given repo. * + * Get the effective storage level for a given repo. * - If cloud sync is enabled and repo is not excluded → 'user' * - Otherwise → 'local' */ @@ -36,16 +42,16 @@ export class SessionIndexingPreference { } /** - * Check if cloud sync is enabled for a given repo. - * Returns true if cloudSync.enabled is true AND the repo is not excluded. + * Check if session sync is enabled for a given repo. + * Returns true if `chat.sessionSync.enabled` is true AND the repo is not excluded. */ hasCloudConsent(repoNwo?: string): boolean { - if (!this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled)) { + if (!(this._configService.getNonExtensionConfig('chat.sessionSync.enabled') ?? false)) { return false; } if (repoNwo) { - const excludePatterns = this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncExcludeRepositories); + const excludePatterns = this._configService.getNonExtensionConfig('chat.sessionSync.excludeRepositories'); if (excludePatterns && excludePatterns.length > 0) { for (const pattern of excludePatterns) { if (pattern === repoNwo || picomatch.isMatch(repoNwo, pattern)) { diff --git a/extensions/copilot/src/extension/chronicle/common/sessionSyncStateService.ts b/extensions/copilot/src/extension/chronicle/common/sessionSyncStateService.ts new file mode 100644 index 0000000000000..67f7d9437bc73 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/common/sessionSyncStateService.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../util/vs/base/common/event'; +import { createServiceIdentifier } from '../../../util/common/services'; + +// ── Service identifier ────────────────────────────────────────────────────────── + +export const ISessionSyncStateService = createServiceIdentifier('ISessionSyncStateService'); + +// ── Types ──────────────────────────────────────────────────────────────────────── + +export type SessionSyncState = + | { kind: 'not-enabled' } + | { kind: 'disabled-by-policy' } + | { kind: 'on' } + | { kind: 'syncing'; sessionCount: number } + | { kind: 'up-to-date'; syncedCount: number } + | { kind: 'deleting'; sessionCount: number } + | { kind: 'error' }; + +// ── Service interface ──────────────────────────────────────────────────────────── + +export interface ISessionSyncStateService { + readonly _serviceBrand: undefined; + + /** The current sync state. */ + readonly syncState: SessionSyncState; + + /** Fires when the sync state changes. */ + readonly onDidChangeSyncState: Event; +} diff --git a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts index d863aa7da9a2c..77a06a4de753b 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts @@ -5,7 +5,8 @@ import { describe, expect, it } from 'vitest'; import type { ICompletedSpanData } from '../../../../platform/otel/common/otelService'; -import { createSessionTranslationState, makeIdleEvent, makeShutdownEvent, translateSpan } from '../eventTranslator'; +import type { IDebugLogEntry } from '../../../../platform/chat/common/chatDebugFileLoggerService'; +import { createSessionTranslationState, makeIdleEvent, makeShutdownEvent, translateDebugLogEntry, translateSpan } from '../eventTranslator'; function makeSpan(overrides: Partial = {}): ICompletedSpanData { return { @@ -238,3 +239,161 @@ describe('makeShutdownEvent', () => { expect(event.parentId).toBe('prev-event-id'); }); }); + +// ── translateDebugLogEntry ────────────────────────────────────────────────── + +function makeDebugEntry(overrides: Partial): IDebugLogEntry { + return { + ts: Date.now(), + dur: 0, + sid: 'session-1', + type: 'generic', + name: '', + spanId: 'span-1', + status: 'ok', + attrs: {}, + ...overrides, + }; +} + +describe('translateDebugLogEntry', () => { + it('emits session.start for session_start entry', () => { + const state = createSessionTranslationState(); + const entry = makeDebugEntry({ + type: 'session_start', + name: 'session_start', + attrs: { cwd: '/workspace', repository: 'microsoft/vscode', branch: 'main' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('session.start'); + expect(events[0].data.sessionId).toBe('sess-1'); + expect(events[0].parentId).toBeNull(); + expect((events[0].data.context as Record).cwd).toBe('/workspace'); + expect((events[0].data.context as Record).repository).toBe('microsoft/vscode'); + expect(state.started).toBe(true); + }); + + it('does not emit duplicate session.start', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ type: 'session_start', name: 'session_start' }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(0); + }); + + it('emits user.message for user_message entry', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'user_message', + name: 'user_message', + attrs: { content: 'Fix the bug' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('user.message'); + expect(events[0].data.content).toBe('Fix the bug'); + }); + + it('emits user.message for turn_start entry with userRequest attr', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'turn_start', + name: 'turn_start', + attrs: { userRequest: 'Add tests' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('user.message'); + expect(events[0].data.content).toBe('Add tests'); + }); + + it('emits assistant.message for agent_response entry', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'agent_response', + name: 'agent_response', + attrs: { response: 'I fixed the bug.' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('assistant.message'); + expect(events[0].data.content).toBe('I fixed the bug.'); + }); + + it('emits tool.execution_complete for tool_call entry', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'tool_call', + name: 'read_file', + spanId: 'tool-span-1', + status: 'ok', + attrs: { result: 'file contents here' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('tool.execution_complete'); + expect(events[0].data.toolName).toBe('read_file'); + expect(events[0].data.toolCallId).toBe('tool-span-1'); + expect(events[0].data.success).toBe(true); + }); + + it('marks tool as failed for error status', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'tool_call', + name: 'apply_patch', + status: 'error', + attrs: { error: 'Patch failed' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events[0].data.success).toBe(false); + }); + + it('chains parentId across entries', () => { + const state = createSessionTranslationState(); + const e1 = makeDebugEntry({ type: 'session_start', name: 'session_start' }); + const e2 = makeDebugEntry({ type: 'user_message', name: 'user_message', attrs: { content: 'hello' } }); + + const events1 = translateDebugLogEntry(e1, 'sess-1', state); + const events2 = translateDebugLogEntry(e2, 'sess-1', state); + + expect(events2[0].parentId).toBe(events1[0].id); + }); + + it('ignores unknown entry types', () => { + const state = createSessionTranslationState(); + const entry = makeDebugEntry({ type: 'generic', name: 'something' }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(0); + }); + + it('truncates oversized user message', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'user_message', + name: 'user_message', + attrs: { content: 'x'.repeat(20_000) }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect((events[0].data.content as string).length).toBeLessThan(20_000); + expect((events[0].data.content as string)).toContain('[truncated]'); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts index 82568f03d9dd0..e5529f88a6bff 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts @@ -7,39 +7,36 @@ import { describe, expect, it } from 'vitest'; import { SessionIndexingPreference } from '../sessionIndexingPreference'; function createMockConfigService(opts: { - localIndexEnabled?: boolean; - cloudSyncEnabled?: boolean; + sessionSyncEnabled?: boolean; excludeRepositories?: string[]; } = {}) { - const configs: Record = {}; - // Map by fullyQualifiedId - configs['github.copilot.chat.localIndex.enabled'] = opts.localIndexEnabled ?? false; - configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false; - configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? []; - return { - getConfig: (key: { fullyQualifiedId: string }) => configs[key.fullyQualifiedId], + getNonExtensionConfig: (key: string) => { + if (key === 'chat.sessionSync.enabled') { + return opts.sessionSyncEnabled ?? false; + } + if (key === 'chat.sessionSync.excludeRepositories') { + return opts.excludeRepositories ?? []; + } + return undefined; + }, } as unknown as import('../../../../platform/configuration/common/configurationService').IConfigurationService; } describe('SessionIndexingPreference', () => { - it('getStorageLevel returns local when no cloud sync', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ localIndexEnabled: true })); + it('getStorageLevel returns local when session sync disabled', () => { + const pref = new SessionIndexingPreference(createMockConfigService()); expect(pref.getStorageLevel()).toBe('local'); }); - it('getStorageLevel returns user when cloud sync enabled', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ - localIndexEnabled: true, - cloudSyncEnabled: true, - })); + it('getStorageLevel returns user when session sync enabled', () => { + const pref = new SessionIndexingPreference(createMockConfigService({ sessionSyncEnabled: true })); expect(pref.getStorageLevel()).toBe('user'); }); it('getStorageLevel returns local for excluded repo', () => { const pref = new SessionIndexingPreference(createMockConfigService({ - localIndexEnabled: true, - cloudSyncEnabled: true, + sessionSyncEnabled: true, excludeRepositories: ['my-org/private-repo'], })); expect(pref.getStorageLevel('my-org/private-repo')).toBe('local'); @@ -47,26 +44,25 @@ describe('SessionIndexingPreference', () => { it('getStorageLevel returns user for non-excluded repo', () => { const pref = new SessionIndexingPreference(createMockConfigService({ - localIndexEnabled: true, - cloudSyncEnabled: true, + sessionSyncEnabled: true, excludeRepositories: ['my-org/private-repo'], })); expect(pref.getStorageLevel('microsoft/vscode')).toBe('user'); }); - it('hasCloudConsent returns false when cloud sync disabled', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ cloudSyncEnabled: false })); + it('hasCloudConsent returns false when session sync disabled', () => { + const pref = new SessionIndexingPreference(createMockConfigService({ sessionSyncEnabled: false })); expect(pref.hasCloudConsent()).toBe(false); }); - it('hasCloudConsent returns true when cloud sync enabled', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ cloudSyncEnabled: true })); + it('hasCloudConsent returns true when session sync enabled', () => { + const pref = new SessionIndexingPreference(createMockConfigService({ sessionSyncEnabled: true })); expect(pref.hasCloudConsent()).toBe(true); }); it('hasCloudConsent returns false for excluded repo', () => { const pref = new SessionIndexingPreference(createMockConfigService({ - cloudSyncEnabled: true, + sessionSyncEnabled: true, excludeRepositories: ['my-org/*'], })); expect(pref.hasCloudConsent('my-org/secret-repo')).toBe(false); @@ -74,7 +70,7 @@ describe('SessionIndexingPreference', () => { it('hasCloudConsent supports glob patterns', () => { const pref = new SessionIndexingPreference(createMockConfigService({ - cloudSyncEnabled: true, + sessionSyncEnabled: true, excludeRepositories: ['private-org/*'], })); expect(pref.hasCloudConsent('private-org/repo-a')).toBe(false); diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts index 7c0268d0ba5ef..d51979d0c4442 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts @@ -15,20 +15,67 @@ const REQUEST_TIMEOUT_MS = 10_000; /** Cloud sessions endpoint path. */ const SESSIONS_PATH = '/agents/sessions'; +// ── Cloud agent application IDs ───────────────────────────────────────────────── + +/** Agent application IDs used by the cloud sessions API (`agent_id` field). */ +export const CloudAgentId = { + VSCodeChat: 797352, + CopilotChat: 894184, + CopilotPRReviews: 946600, + CopilotDeveloper: 1143301, + CopilotDeveloperCLI: 1693627, +} as const; + /** * HTTP client for the cloud session API. * * Creates sessions and submits event batches. All methods are non-blocking: * failures are logged but never thrown to avoid disrupting the chat session. + * + * Respects HTTP 429 (Too Many Requests) by backing off all requests until + * the Retry-After period expires. */ export class CloudSessionApiClient { + /** Timestamp (epoch ms) until which all requests should be skipped due to 429. */ + private _rateLimitedUntil = 0; + + /** Number of times we've been rate-limited. */ + private _rateLimitCount = 0; + + /** Callback fired when a 429 is received. */ + onRateLimited: ((callSite: string, retryAfterSec: number) => void) | undefined; + constructor( private readonly _tokenManager: ICopilotTokenManager, private readonly _authService: IAuthenticationService, private readonly _fetcherService: IFetcherService, ) { } + /** Returns true if we're currently rate-limited and should skip requests. */ + private _isRateLimited(): boolean { + return Date.now() < this._rateLimitedUntil; + } + + /** Record a 429 response and back off for the indicated duration. */ + private _handleRateLimit(res: { headers?: { get?(name: string): string | null } }, callSite: string): void { + let retryAfterSec = 60; // Default: 60 seconds + try { + const header = res.headers?.get?.('Retry-After'); + if (header) { + const parsed = parseInt(header, 10); + if (!isNaN(parsed) && parsed > 0 && parsed <= 600) { + retryAfterSec = parsed; + } + } + } catch { + // Use default + } + this._rateLimitedUntil = Date.now() + retryAfterSec * 1000; + this._rateLimitCount++; + this.onRateLimited?.(callSite, retryAfterSec); + } + /** * Create a session in the cloud. * @@ -40,6 +87,9 @@ export class CloudSessionApiClient { sessionId: string, indexingLevel: 'user' | 'repo_and_user' = 'user', ): Promise { + if (this._isRateLimited()) { + return { ok: false, reason: 'error' }; + } try { const { url, headers } = await this._buildRequest(SESSIONS_PATH); if (!url) { @@ -61,6 +111,11 @@ export class CloudSessionApiClient { timeout: REQUEST_TIMEOUT_MS, }); + if (res.status === 429) { + this._handleRateLimit(res, 'createSession'); + return { ok: false, reason: 'error' }; + } + if (!res.ok) { const reason: CreateSessionFailureReason = res.status === 403 ? 'policy_blocked' : 'error'; return { ok: false, reason }; @@ -81,6 +136,9 @@ export class CloudSessionApiClient { sessionId: string, events: SessionEvent[], ): Promise { + if (this._isRateLimited()) { + return false; + } try { const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/events`); if (!url) { @@ -95,6 +153,11 @@ export class CloudSessionApiClient { timeout: REQUEST_TIMEOUT_MS, }); + if (res.status === 429) { + this._handleRateLimit(res, 'submitEvents'); + return false; + } + if (!res.ok) { return false; } @@ -109,6 +172,9 @@ export class CloudSessionApiClient { * Get a session by ID (used for reattach verification). */ async getSession(sessionId: string): Promise { + if (this._isRateLimited()) { + return undefined; + } try { const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}`); if (!url) { @@ -122,6 +188,11 @@ export class CloudSessionApiClient { timeout: REQUEST_TIMEOUT_MS, }); + if (res.status === 429) { + this._handleRateLimit(res, 'getSession'); + return undefined; + } + if (!res.ok) { return undefined; } @@ -132,6 +203,142 @@ export class CloudSessionApiClient { } } + /** + * List VS Code cloud sessions for the authenticated user. + * Paginates through all pages and filters to only VS Code Chat sessions. + */ + async listSessions(): Promise> { + const allSessions: Array<{ id: string; agent_task_id?: string; agent_id?: number; state: string; created_at: string }> = []; + if (this._isRateLimited()) { + return allSessions; + } + const pageSize = 100; + let page = 1; + + try { + while (true) { + const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}?page_size=${pageSize}&page_number=${page}`); + if (!url) { + return allSessions; + } + + const res = await this._fetcherService.fetch(url, { + callSite: 'chronicle.cloudListSessions', + method: 'GET', + headers, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (res.status === 429) { + this._handleRateLimit(res, 'listSessions'); + return allSessions; + } + + if (!res.ok) { + return allSessions; + } + + const data = await res.json(); + const sessions = Array.isArray(data) ? data : (data as Record).sessions; + const pageSessions = Array.isArray(sessions) ? sessions : []; + + // Filter to VS Code Chat sessions only + for (const session of pageSessions) { + if (session.agent_id === CloudAgentId.VSCodeChat) { + allSessions.push(session); + } + } + + // Stop if we got fewer than a full page (last page) + if (pageSessions.length < pageSize) { + break; + } + page++; + } + } catch { + // Return whatever we've collected so far + } + + return allSessions; + } + + /** + * Delete a session from the cloud. + * Returns 'deleted' if queued for deletion (202), 'not_found' if the session + * doesn't exist in the cloud (404, treated as success), or 'error' on failure. + */ + async deleteSession(sessionId: string): Promise<'deleted' | 'not_found' | 'error'> { + if (this._isRateLimited()) { + return 'error'; + } + try { + const { url, headers } = await this._buildRequest('/agents/analytics/delete'); + if (!url) { + return 'error'; + } + + const res = await this._fetcherService.fetch(url, { + callSite: 'chronicle.cloudDeleteSession', + method: 'POST', + headers, + json: { session_id: sessionId }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (res.status === 429) { + this._handleRateLimit(res, 'deleteSession'); + return 'error'; + } + if (res.status === 202) { + return 'deleted'; + } + if (res.status === 404) { + return 'not_found'; + } + return 'error'; + } catch { + return 'error'; + } + } + + /** + * Trigger bulk analytics backfill for all remote sessions at the given indexing level. + * Single API call that queues all eligible sessions for reindexing. + */ + async backfillAnalytics(indexingLevel: 'user' | 'repo_and_user'): Promise<{ ok: true; sessionsQueued: number } | { ok: false }> { + if (this._isRateLimited()) { + return { ok: false }; + } + try { + const { url, headers } = await this._buildRequest('/agents/analytics/backfill'); + if (!url) { + return { ok: false }; + } + + const res = await this._fetcherService.fetch(url, { + callSite: 'chronicle.cloudBackfillAnalytics', + method: 'POST', + headers, + json: { indexing_level: indexingLevel }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (res.status === 429) { + this._handleRateLimit(res, 'backfillAnalytics'); + return { ok: false }; + } + + if (!res.ok) { + return { ok: false }; + } + + const data = await res.json() as { sessions_queued?: number }; + return { ok: true, sessionsQueued: data.sessions_queued ?? 0 }; + } catch { + return { ok: false }; + } + } + /** * Build the full URL and auth headers for a cloud API request. */ diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionIdStore.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionIdStore.ts new file mode 100644 index 0000000000000..ab2b5b9b13a4e --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionIdStore.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import type { CloudSessionIds } from '../common/cloudSessionTypes'; + +const FILE_NAME = 'cloudSessions.json'; + +/** + * JSON-backed store for cloud session ID mappings. + * + * Persists `{ localSessionId → { cloudSessionId, cloudTaskId } }` to a JSON + * file in globalStorageUri so that mappings survive across VS Code restarts. + * This store is always available regardless of the `chat.localIndex.enabled` + * setting (unlike the SQLite session store). + * + * All writes are fire-and-forget — disk errors are silently swallowed. + * Reads are cached in an in-memory Map for fast lookup. + */ +export class CloudSessionIdStore { + + private readonly _filePath: string; + private readonly _map = new Map(); + private _loaded = false; + private _persistScheduled = false; + private _dirEnsured = false; + + constructor(globalStoragePath: string) { + this._filePath = path.join(globalStoragePath, FILE_NAME); + } + + /** + * Load from disk into memory (async, idempotent). + * Called once at startup — the in-memory map is empty until this resolves. + */ + async load(): Promise { + if (this._loaded) { + return; + } + this._loaded = true; + try { + const raw = await fsp.readFile(this._filePath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + for (const [key, value] of Object.entries(parsed)) { + if (value && typeof value.cloudSessionId === 'string' && typeof value.cloudTaskId === 'string') { + this._map.set(key, value); + } + } + } catch { + // File doesn't exist or is corrupted — start fresh + } + } + + get size(): number { + return this._map.size; + } + + has(sessionId: string): boolean { + return this._map.has(sessionId); + } + + get(sessionId: string): CloudSessionIds | undefined { + return this._map.get(sessionId); + } + + keys(): IterableIterator { + return this._map.keys(); + } + + set(sessionId: string, ids: CloudSessionIds): void { + this._map.set(sessionId, ids); + this._schedulePersist(); + } + + delete(sessionId: string): boolean { + const existed = this._map.delete(sessionId); + if (existed) { + this._schedulePersist(); + } + return existed; + } + + clear(): void { + this._map.clear(); + this._schedulePersist(); + } + + /** + * Merge cloud session list into the store (additive — does not remove + * entries that aren't in the cloud list, since those may be from other + * windows that haven't synced yet). + */ + mergeFromCloud(entries: Array<{ id: string; agent_task_id?: string }>): void { + let changed = false; + for (const entry of entries) { + if (entry.agent_task_id && !this._map.has(entry.agent_task_id)) { + this._map.set(entry.agent_task_id, { + cloudSessionId: entry.id, + cloudTaskId: entry.agent_task_id, + }); + changed = true; + } + } + if (changed) { + this._schedulePersist(); + } + } + + /** + * Coalesce multiple rapid mutations into a single async disk write. + * Uses queueMicrotask so all synchronous set/delete calls in the + * same turn batch into one write. + */ + private _schedulePersist(): void { + if (this._persistScheduled) { + return; + } + this._persistScheduled = true; + queueMicrotask(() => { + this._persistScheduled = false; + this._persist().catch(() => { /* best effort */ }); + }); + } + + private async _persist(): Promise { + try { + if (!this._dirEnsured) { + const dir = path.dirname(this._filePath); + await fsp.mkdir(dir, { recursive: true }); + this._dirEnsured = true; + } + const data: Record = {}; + for (const [key, value] of this._map) { + data[key] = value; + } + await fsp.writeFile(this._filePath, JSON.stringify(data), 'utf-8'); + } catch { + // Best effort — don't block callers + } + } +} diff --git a/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts b/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts index 72b3743238387..3ef024a7d5fb5 100644 --- a/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts +++ b/extensions/copilot/src/extension/chronicle/node/sessionReindexer.ts @@ -7,6 +7,11 @@ import * as l10n from '@vscode/l10n'; import type { IChatDebugFileLoggerService, IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService'; import type { ISessionStore, SessionRow, TurnRow, FileRow, RefRow } from '../../../platform/chronicle/common/sessionStore'; import type { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import type { SessionEvent } from '../common/cloudSessionTypes'; +import type { CloudSessionApiClient } from './cloudSessionApiClient'; +import type { CloudSessionIdStore } from './cloudSessionIdStore'; +import { createSessionTranslationState, translateDebugLogEntry, makeShutdownEvent } from '../common/eventTranslator'; +import { filterSecretsFromObj } from '../common/secretFilter'; import { MAX_ASSISTANT_RESPONSE_LENGTH, MAX_SUMMARY_LENGTH, @@ -319,3 +324,211 @@ function processToolCall( } } } + +// ── Cloud reindex ──────────────────────────────────────────────────────────────── + +/** Max events per upload batch. */ +const MAX_EVENTS_PER_UPLOAD = 500; + +/** + * Result of the cloud reindex phase. + */ +export interface CloudReindexResult { + /** Number of cloud sessions created. */ + created: number; + /** Total number of events uploaded. */ + eventsUploaded: number; + /** Number of sessions that failed cloud creation or upload. */ + failed: number; + /** Number of sessions queued for analytics backfill. */ + backfillQueued: number; + /** Whether the backfill API call failed. */ + backfillFailed?: boolean; +} + +/** + * Upload historical sessions to the cloud for sessions that lack a cloud + * counterpart. Follows the CLI reindex pattern: + * + * 1. For each local session not in {@link cloudSessionIds}: create cloud + * session, stream JSONL entries, translate to cloud events, upload in + * batches of 500. + * 2. After all sessions: single `backfillAnalytics()` call. + * + * All operations are non-blocking (yields between sessions) and bounded + * in memory (events are flushed in batches, buffers cleared after upload). + */ +export async function reindexCloudSessions( + cloudClient: CloudSessionApiClient, + cloudSessionIds: CloudSessionIdStore, + debugLogService: IChatDebugFileLoggerService, + ownerId: number, + repoId: number, + indexingLevel: 'user' | 'repo_and_user', + reportProgress: (message: string) => void, + token: CancellationToken, + isRepoExcluded?: (repoNwo: string) => boolean, +): Promise { + const result: CloudReindexResult = { + created: 0, + eventsUploaded: 0, + failed: 0, + backfillQueued: 0, + }; + + await cloudSessionIds.load(); + const sessionIds = await debugLogService.listSessionIds(); + let processed = 0; + + for (const sessionId of sessionIds) { + if (token.isCancellationRequested) { + break; + } + + // Skip sessions already synced to cloud + if (cloudSessionIds.has(sessionId)) { + processed++; + continue; + } + + processed++; + if (processed % 10 === 0) { + reportProgress(l10n.t('Cloud sync: {0}/{1} sessions scanned, {2} created...', processed, sessionIds.length, result.created)); + } + + try { + await reindexOneCloudSession(sessionId, cloudClient, cloudSessionIds, debugLogService, ownerId, repoId, indexingLevel, result, isRepoExcluded); + } catch { + result.failed++; + } + + // Yield to event loop between sessions + await new Promise(resolve => setTimeout(resolve, 0)); + } + + // Single bulk backfill call for all remote sessions + if (!token.isCancellationRequested) { + const backfillResult = await cloudClient.backfillAnalytics(indexingLevel); + if (backfillResult.ok) { + result.backfillQueued = backfillResult.sessionsQueued; + } else { + result.backfillFailed = true; + } + } + + return result; +} + +/** + * Process a single session for cloud reindex: create cloud session, + * stream entries, translate, upload in batches. + */ +async function reindexOneCloudSession( + sessionId: string, + cloudClient: CloudSessionApiClient, + cloudSessionIds: CloudSessionIdStore, + debugLogService: IChatDebugFileLoggerService, + ownerId: number, + repoId: number, + indexingLevel: 'user' | 'repo_and_user', + result: CloudReindexResult, + isRepoExcluded?: (repoNwo: string) => boolean, +): Promise { + // Stream entries, check repo exclusion, and translate to cloud events + const state = createSessionTranslationState(); + const batch: SessionEvent[] = []; + let sessionRepo: string | undefined; + let excluded = false; + + await debugLogService.streamEntries(sessionId, (entry: IDebugLogEntry) => { + // Extract repo from session_start for exclusion check + if (entry.type === 'session_start' && typeof entry.attrs.repository === 'string') { + sessionRepo = entry.attrs.repository; + if (isRepoExcluded) { + const nwo = extractNwoFromRepoString(sessionRepo); + if (nwo && isRepoExcluded(nwo)) { + excluded = true; + } + } + } + // Skip translation if repo is excluded + if (excluded) { + return; + } + const events = translateDebugLogEntry(entry, sessionId, state); + for (const event of events) { + batch.push(event); + } + }); + + if (excluded) { + batch.length = 0; + return; + } + + // Create cloud session + const createResult = await cloudClient.createSession(ownerId, repoId, sessionId, indexingLevel); + if (!createResult.ok || !createResult.response.task_id) { + result.failed++; + batch.length = 0; + return; + } + + const cloudSessionId = createResult.response.id; + const cloudTaskId = createResult.response.task_id; + + // Add shutdown event + if (state.started) { + batch.push(makeShutdownEvent(state)); + } + + // Upload in batches + let uploaded = 0; + let uploadFailed = false; + for (let i = 0; i < batch.length; i += MAX_EVENTS_PER_UPLOAD) { + const chunk = batch.slice(i, i + MAX_EVENTS_PER_UPLOAD); + const filtered = chunk.map(e => filterSecretsFromObj(e)); + const success = await cloudClient.submitSessionEvents(cloudSessionId, filtered); + if (success) { + uploaded += chunk.length; + } else { + uploadFailed = true; + break; + } + } + + // Clear batch to release memory + batch.length = 0; + + // Only persist IDs and count as created when all chunks uploaded successfully. + // If upload failed, leave the session eligible for retry on next reindex. + if (uploadFailed) { + result.failed++; + } else { + cloudSessionIds.set(sessionId, { cloudSessionId, cloudTaskId }); + result.created++; + } + result.eventsUploaded += uploaded; +} + +/** + * Extract `owner/repo` from a repository string that may be a full URL + * (e.g. `https://github.com/owner/repo.git`) or already `owner/repo`. + */ +function extractNwoFromRepoString(repo: string): string | undefined { + // Already in owner/repo format + if (/^[^/]+\/[^/]+$/.test(repo)) { + return repo; + } + // URL format: extract from path + try { + const url = new URL(repo); + const parts = url.pathname.replace(/\.git$/, '').split('/').filter(Boolean); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + } catch { + // Not a valid URL + } + return undefined; +} diff --git a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionIdStore.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionIdStore.spec.ts new file mode 100644 index 0000000000000..3fa64ab6d9687 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionIdStore.spec.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { CloudSessionIdStore } from '../cloudSessionIdStore'; + +describe('CloudSessionIdStore', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cloud-session-store-test-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => { }); + }); + + it('starts empty when no file exists', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + expect(store.size).toBe(0); + expect(store.has('session-1')).toBe(false); + }); + + it('set and get work correctly', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + store.set('session-1', { cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + + expect(store.has('session-1')).toBe(true); + expect(store.get('session-1')).toEqual({ cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + expect(store.size).toBe(1); + }); + + it('delete removes entry and returns true', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + store.set('session-1', { cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + + const existed = store.delete('session-1'); + expect(existed).toBe(true); + expect(store.has('session-1')).toBe(false); + expect(store.size).toBe(0); + }); + + it('delete returns false for nonexistent entry', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + expect(store.delete('nonexistent')).toBe(false); + }); + + it('persists data and survives reload', async () => { + const store1 = new CloudSessionIdStore(tmpDir); + await store1.load(); + store1.set('session-1', { cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + store1.set('session-2', { cloudSessionId: 'cloud-2', cloudTaskId: 'task-2' }); + + // Wait for async persist + await new Promise(resolve => setTimeout(resolve, 50)); + + const store2 = new CloudSessionIdStore(tmpDir); + await store2.load(); + expect(store2.size).toBe(2); + expect(store2.get('session-1')).toEqual({ cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + expect(store2.get('session-2')).toEqual({ cloudSessionId: 'cloud-2', cloudTaskId: 'task-2' }); + }); + + it('delete persists removal', async () => { + const store1 = new CloudSessionIdStore(tmpDir); + await store1.load(); + store1.set('session-1', { cloudSessionId: 'cloud-1', cloudTaskId: 'task-1' }); + + // Wait for persist + await new Promise(resolve => setTimeout(resolve, 50)); + + store1.delete('session-1'); + + // Wait for persist + await new Promise(resolve => setTimeout(resolve, 50)); + + const store2 = new CloudSessionIdStore(tmpDir); + await store2.load(); + expect(store2.size).toBe(0); + }); + + it('mergeFromCloud adds new entries without removing existing', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + store.set('local-1', { cloudSessionId: 'cloud-local', cloudTaskId: 'local-1' }); + + store.mergeFromCloud([ + { id: 'cloud-remote', agent_task_id: 'remote-1' }, + { id: 'cloud-local', agent_task_id: 'local-1' }, // already exists — should not overwrite + ]); + + expect(store.size).toBe(2); + expect(store.get('remote-1')).toEqual({ cloudSessionId: 'cloud-remote', cloudTaskId: 'remote-1' }); + // Original entry preserved + expect(store.get('local-1')).toEqual({ cloudSessionId: 'cloud-local', cloudTaskId: 'local-1' }); + }); + + it('mergeFromCloud skips entries without agent_task_id', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + + store.mergeFromCloud([ + { id: 'cloud-1', agent_task_id: undefined as any }, + { id: 'cloud-2', agent_task_id: '' }, + { id: 'cloud-3', agent_task_id: 'valid-task' }, + ]); + + expect(store.size).toBe(1); + expect(store.has('valid-task')).toBe(true); + }); + + it('keys returns all session IDs', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + store.set('session-1', { cloudSessionId: 'c1', cloudTaskId: 't1' }); + store.set('session-2', { cloudSessionId: 'c2', cloudTaskId: 't2' }); + + const keys = [...store.keys()]; + expect(keys).toContain('session-1'); + expect(keys).toContain('session-2'); + expect(keys).toHaveLength(2); + }); + + it('clear removes all entries', async () => { + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + store.set('session-1', { cloudSessionId: 'c1', cloudTaskId: 't1' }); + store.set('session-2', { cloudSessionId: 'c2', cloudTaskId: 't2' }); + + store.clear(); + expect(store.size).toBe(0); + }); + + it('load is idempotent', async () => { + const store = new CloudSessionIdStore(tmpDir); + store.set('session-1', { cloudSessionId: 'c1', cloudTaskId: 't1' }); + + // Wait for persist + await new Promise(resolve => setTimeout(resolve, 50)); + + await store.load(); + await store.load(); // Second call should be no-op + expect(store.size).toBe(1); + }); + + it('handles corrupted JSON file gracefully', async () => { + await fsp.writeFile(path.join(tmpDir, 'cloudSessions.json'), 'not valid json', 'utf-8'); + + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + expect(store.size).toBe(0); // Should start fresh + }); + + it('handles malformed entries in JSON file', async () => { + const data = { + 'valid': { cloudSessionId: 'c1', cloudTaskId: 't1' }, + 'missing-cloud-id': { cloudTaskId: 't2' }, + 'missing-task-id': { cloudSessionId: 'c3' }, + 'null-entry': null, + }; + await fsp.writeFile(path.join(tmpDir, 'cloudSessions.json'), JSON.stringify(data), 'utf-8'); + + const store = new CloudSessionIdStore(tmpDir); + await store.load(); + expect(store.size).toBe(1); // Only the valid entry + expect(store.has('valid')).toBe(true); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts index fcacbd95d9f74..9a84aaeb55bdc 100644 --- a/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts +++ b/extensions/copilot/src/extension/chronicle/node/test/sessionReindexer.spec.ts @@ -7,7 +7,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { IChatDebugFileLoggerService, IDebugLogEntry } from '../../../../platform/chat/common/chatDebugFileLoggerService'; import type { ISessionStore, SessionRow, TurnRow, FileRow, RefRow } from '../../../../platform/chronicle/common/sessionStore'; import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; -import { reindexSessions } from '../sessionReindexer'; +import { reindexSessions, reindexCloudSessions } from '../sessionReindexer'; +import type { CloudSessionApiClient } from '../cloudSessionApiClient'; +import type { CloudSessionIdStore } from '../cloudSessionIdStore'; +import type { CloudSessionIds } from '../../common/cloudSessionTypes'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -49,6 +52,7 @@ function createMockStore(): MockSessionStore { insertFile: (f: FileRow) => mock.insertedFiles.push(f), insertRef: (r: RefRow) => mock.insertedRefs.push(r), indexWorkspaceArtifact: () => { }, + deleteSession: () => { }, search: () => [], getSession: (id: string) => mock.existingSessions.has(id) ? { id } as SessionRow : undefined, getTurns: () => [], @@ -349,3 +353,227 @@ describe('reindexSessions', () => { expect(result).toEqual({ processed: 0, skipped: 0, cancelled: false }); }); }); + +// ── Cloud reindex tests ────────────────────────────────────────────────────── + +function createMockCloudClient(overrides: Partial = {}): CloudSessionApiClient { + return { + createSession: vi.fn().mockResolvedValue({ + ok: true, + response: { id: 'cloud-session-1', task_id: 'task-1' }, + }), + submitSessionEvents: vi.fn().mockResolvedValue(true), + backfillAnalytics: vi.fn().mockResolvedValue({ ok: true, sessionsQueued: 5 }), + listSessions: vi.fn().mockResolvedValue([]), + getSession: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue('deleted'), + ...overrides, + } as unknown as CloudSessionApiClient; +} + +function createMockCloudSessionIdStore(existingIds: Map = new Map()): CloudSessionIdStore { + const map = new Map(existingIds); + return { + load: vi.fn().mockResolvedValue(undefined), + has: (id: string) => map.has(id), + get: (id: string) => map.get(id), + set: vi.fn((id: string, ids: CloudSessionIds) => { map.set(id, ids); }), + delete: vi.fn((id: string) => map.delete(id)), + get size() { return map.size; }, + keys: () => map.keys(), + clear: vi.fn(), + mergeFromCloud: vi.fn(), + } as unknown as CloudSessionIdStore; +} + +describe('reindexCloudSessions', () => { + it('creates cloud sessions for local sessions not yet synced', async () => { + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-1', attrs: { cwd: '/workspace' } }), + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Fix it' } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cloudClient = createMockCloudClient(); + const cloudStore = createMockCloudSessionIdStore(); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(1); + expect(result.eventsUploaded).toBeGreaterThan(0); + expect(cloudClient.createSession).toHaveBeenCalledWith(123, 456, 'session-1', 'user'); + expect(cloudStore.set).toHaveBeenCalledWith('session-1', { cloudSessionId: 'cloud-session-1', cloudTaskId: 'task-1' }); + expect(cloudClient.backfillAnalytics).toHaveBeenCalledWith('user'); + expect(result.backfillQueued).toBe(5); + }); + + it('skips sessions already in the cloud store', async () => { + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Hello' } }), + ]); + + const existing = new Map([ + ['session-1', { cloudSessionId: 'existing-cloud', cloudTaskId: 'existing-task' }], + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cloudClient = createMockCloudClient(); + const cloudStore = createMockCloudSessionIdStore(existing); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(0); + expect(cloudClient.createSession).not.toHaveBeenCalled(); + }); + + it('handles cloud session creation failure', async () => { + const entries = new Map(); + entries.set('session-1', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Hello' } }), + ]); + + const debugLog = createMockDebugLogService(['session-1'], entries); + const cloudClient = createMockCloudClient({ + createSession: vi.fn().mockResolvedValue({ ok: false, reason: 'error' }) as any, + }); + const cloudStore = createMockCloudSessionIdStore(); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(0); + expect(result.failed).toBe(1); + expect(cloudStore.set).not.toHaveBeenCalled(); + }); + + it('respects cancellation token', async () => { + const entries = new Map(); + entries.set('session-1', [makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-1', attrs: { content: 'Hello' } })]); + entries.set('session-2', [makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-2', attrs: { content: 'World' } })]); + + const debugLog = createMockDebugLogService(['session-1', 'session-2'], entries); + const cloudClient = createMockCloudClient(); + const cloudStore = createMockCloudSessionIdStore(); + const cts = new CancellationTokenSource(); + cts.cancel(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(0); + expect(cloudClient.backfillAnalytics).not.toHaveBeenCalled(); + }); + + it('handles backfill failure gracefully', async () => { + const debugLog = createMockDebugLogService([], new Map()); + const cloudClient = createMockCloudClient({ + backfillAnalytics: vi.fn().mockResolvedValue({ ok: false }) as any, + }); + const cloudStore = createMockCloudSessionIdStore(); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.backfillFailed).toBe(true); + }); + + it('handles mixed sessions: some synced, some new, some failing', async () => { + const entries = new Map(); + entries.set('session-new', [ + makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-new' }), + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-new', attrs: { content: 'Hello' } }), + ]); + entries.set('session-existing', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-existing', attrs: { content: 'World' } }), + ]); + entries.set('session-fail', [ + makeEntry({ type: 'user_message', name: 'user_message', sid: 'session-fail', attrs: { content: 'Fail' } }), + ]); + + const existing = new Map([ + ['session-existing', { cloudSessionId: 'cloud-existing', cloudTaskId: 'task-existing' }], + ]); + + let callCount = 0; + const cloudClient = createMockCloudClient({ + createSession: vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 2) { + return { ok: false, reason: 'error' }; + } + return { ok: true, response: { id: `cloud-${callCount}`, task_id: `task-${callCount}` } }; + }) as any, + }); + + const debugLog = createMockDebugLogService(['session-new', 'session-existing', 'session-fail'], entries); + const cloudStore = createMockCloudSessionIdStore(existing); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(1); // session-new succeeded + expect(result.failed).toBe(1); // session-fail failed creation + // session-existing was skipped (already synced) + expect(cloudClient.createSession).toHaveBeenCalledTimes(2); // Only new + fail, not existing + }); + + it('uploads events in batches and cleans up', async () => { + // Create a session with many entries to test batching + const manyEntries: IDebugLogEntry[] = [ + makeEntry({ type: 'session_start', name: 'session_start', sid: 'session-big' }), + ]; + for (let i = 0; i < 10; i++) { + manyEntries.push(makeEntry({ + type: 'user_message', + name: 'user_message', + sid: 'session-big', + attrs: { content: `Message ${i}` }, + })); + manyEntries.push(makeEntry({ + type: 'agent_response', + name: 'agent_response', + sid: 'session-big', + attrs: { response: `Response ${i}` }, + })); + } + + const entries = new Map(); + entries.set('session-big', manyEntries); + + const debugLog = createMockDebugLogService(['session-big'], entries); + const cloudClient = createMockCloudClient(); + const cloudStore = createMockCloudSessionIdStore(); + const cts = new CancellationTokenSource(); + + const result = await reindexCloudSessions( + cloudClient, cloudStore, debugLog, + 123, 456, 'user', vi.fn(), cts.token, + ); + + expect(result.created).toBe(1); + // 1 session_start + 10 user + 10 assistant + 1 shutdown = 22 events + expect(result.eventsUploaded).toBe(22); + expect(cloudClient.submitSessionEvents).toHaveBeenCalled(); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index 240b8f4947f16..ad3b81f43f39b 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; import { IChatSessionService } from '../../../platform/chat/common/chatSessionService'; +import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; +import { ISessionStore } from '../../../platform/chronicle/common/sessionStore'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../platform/otel/common/genAiAttributes'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; @@ -13,7 +16,8 @@ import { type ICompletedSpanData, IOTelService } from '../../../platform/otel/co import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService'; import { IGithubRepositoryService } from '../../../platform/github/common/githubService'; import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle'; -import { autorun } from '../../../util/vs/base/common/observableInternal'; +import { Emitter } from '../../../util/vs/base/common/event'; +import { autorun, observableFromEventOpts } from '../../../util/vs/base/common/observableInternal'; import { IExtensionContribution } from '../../common/contributions'; import { CircuitBreaker } from '../common/circuitBreaker'; import { @@ -28,6 +32,10 @@ import { SessionIndexingPreference, type SessionIndexingLevel } from '../common/ import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { CloudSessionApiClient } from '../node/cloudSessionApiClient'; +import { ISessionSyncStateService, type SessionSyncState } from '../common/sessionSyncStateService'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { CloudSessionIdStore } from '../node/cloudSessionIdStore'; +import { reindexCloudSessions, type CloudReindexResult } from '../node/sessionReindexer'; // ── Configuration ─────────────────────────────────────────────────────────────── @@ -55,13 +63,21 @@ const SOFT_BUFFER_CAP = 500; * - Lazy initialization: no work until the first real chat interaction * * All cloud operations are fire-and-forget — never blocks or slows the chat session. + * + * Also implements ISessionSyncStateService so that SessionSyncStatus can + * observe the current sync state via dependency injection. */ -export class RemoteSessionExporter extends Disposable implements IExtensionContribution { +export class RemoteSessionExporter extends Disposable implements IExtensionContribution, ISessionSyncStateService { + + declare readonly _serviceBrand: undefined; // ── Per-session state ──────────────────────────────────────────────────────── - /** Per-session cloud IDs (created lazily on first interaction). */ - private readonly _cloudSessions = new Map(); + /** Per-session cloud IDs — persisted to globalStorage JSON file. */ + private readonly _cloudSessions: CloudSessionIdStore; + + /** Whether we've reconciled the disk cache with the cloud API this window. */ + private _cloudReconciled = false; /** Per-session translation state (parentId chaining, session.start tracking). */ private readonly _translationStates = new Map(); @@ -93,6 +109,90 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr /** User's session indexing preference (resolved once per repo). */ private readonly _indexingPreference: SessionIndexingPreference; + /** Whether the session sync suggestion notification has been shown. */ + private _syncSuggestionShown = false; + + // ── Sync state & status item ──────────────────────────────────────────────── + + private readonly _onDidChangeSyncState = this._register(new Emitter()); + readonly onDidChangeSyncState = this._onDidChangeSyncState.event; + private _syncState: SessionSyncState = { kind: 'not-enabled' }; + get syncState(): SessionSyncState { return this._syncState; } + + private _setSyncState(state: SessionSyncState): void { + this._syncState = state; + this._onDidChangeSyncState.fire(state); + } + + /** Cached local synced count — invalidated on set/delete of cloud sessions. */ + private _cachedLocalSyncedCount: number | undefined; + + /** + * Count sessions from this machine that are synced to the cloud. + * Cross-references SQLite (local sessions) with the cloud session ID store. + * Cached to avoid repeated SQL queries on every flush. + * Falls back to the full cloud store size if SQLite is unavailable. + */ + private _getLocalSyncedCount(): number { + if (this._cachedLocalSyncedCount !== undefined) { + return this._cachedLocalSyncedCount; + } + try { + const localIds = this._sessionStore.executeReadOnlyFallback( + 'SELECT id FROM sessions LIMIT 1000' + ) as Array<{ id: string }>; + let count = 0; + for (const row of localIds) { + if (this._cloudSessions.has(row.id)) { + count++; + } + } + this._cachedLocalSyncedCount = count; + return count; + } catch { + // SQLite unavailable — fall back to full cloud store size + return this._cloudSessions.size; + } + } + + /** Invalidate the cached local synced count (call after cloud session set/delete). */ + private _invalidateLocalSyncedCount(): void { + this._cachedLocalSyncedCount = undefined; + } + + /** + * Load cloud session IDs from disk (no network). + * The disk file provides instant ID lookups and status bar count. + * Fire-and-forget — errors are silently swallowed. + */ + private async _loadFromDisk(): Promise { + await this._cloudSessions.load(); + if (this._cloudSessions.size > 0 && this._syncState.kind === 'on') { + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + } + } + + /** + * Reconcile the local disk cache with the cloud sessions API. + * Called lazily on first delete or reindex — not at startup. + * Idempotent within a window lifetime. + */ + private async _reconcileWithCloud(): Promise { + if (this._cloudReconciled) { + return; + } + this._cloudReconciled = true; + await this._cloudSessions.load(); + try { + const cloudSessions = await this._cloudClient.listSessions(); + this._cloudSessions.mergeFromCloud(cloudSessions); + this._invalidateLocalSyncedCount(); + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + } catch { + // Non-fatal — disk cache is good enough for ID lookups + } + } + constructor( @IOTelService private readonly _otelService: IOTelService, @IChatSessionService private readonly _chatSessionService: IChatSessionService, @@ -104,31 +204,81 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr @IExperimentationService private readonly _expService: IExperimentationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IFetcherService private readonly _fetcherService: IFetcherService, + @ISessionStore private readonly _sessionStore: ISessionStore, + @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, + @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, ) { super(); + this._cloudSessions = new CloudSessionIdStore(this._extensionContext.globalStorageUri.fsPath); this._indexingPreference = new SessionIndexingPreference(this._configService); this._cloudClient = new CloudSessionApiClient(this._tokenManager, this._authService, this._fetcherService); + this._cloudClient.onRateLimited = (callSite, retryAfterSec) => { + this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { + operation: 'rateLimited', + error: callSite, + }, { + retryAfterSec, + }); + }; this._circuitBreaker = new CircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 1_000, maxResetTimeoutMs: 30_000, }); + // Register delete cloud sessions command + this._register(vscode.commands.registerCommand('github.copilot.sessionSync.deleteSessions', () => this._deleteCloudSessions())); + + // Register cloud-only delete for sessions window hook (fire-and-forget, no UI) + this._register(vscode.commands.registerCommand('github.copilot.sessionSync.deleteSessionFromCloud', (sessionIds: string[]) => this._deleteSessionsFromCloud(sessionIds))); + + // Register suggest session sync command (called from chronicleIntent when user runs /chronicle) + this._register(vscode.commands.registerCommand('github.copilot.sessionSync.suggest', () => this._suggestSessionSync())); + + // Register cloud reindex command (called from chronicleIntent after local reindex) + this._register(vscode.commands.registerCommand('github.copilot.sessionSync.reindex', (reportProgress: (msg: string) => void, token: vscode.CancellationToken) => this._reindexCloud(reportProgress, token))); + // Register known auth tokens as dynamic secrets for filtering this._registerAuthSecrets(); // Only set up span listener when both local index and cloud sync are enabled. // Uses autorun to react if settings change at runtime. const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); - const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); + const cloudEnabled = observableFromEventOpts( + { debugName: 'chat.sessionSync.enabled' }, + handler => this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('chat.sessionSync.enabled')) { + handler(e); + } + })), + () => this._configService.getNonExtensionConfig('chat.sessionSync.enabled') ?? false, + ); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { spanListenerStore.clear(); - if (!localEnabled.read(reader) || !cloudEnabled.read(reader)) { + const isLocalEnabled = localEnabled.read(reader); + const isCloudEnabled = cloudEnabled.read(reader); + + if (!isLocalEnabled || !isCloudEnabled) { + // Distinguish "disabled by policy" from "not enabled by user" + if (isLocalEnabled && !isCloudEnabled) { + const inspection = vscode.workspace.getConfiguration().inspect('chat.sessionSync.enabled'); + if ((inspection as { policyValue?: boolean } | undefined)?.policyValue === false) { + this._setSyncState({ kind: 'disabled-by-policy' }); + return; + } + } + this._setSyncState({ kind: 'not-enabled' }); return; } + // Cloud sync is active — set initial state + this._setSyncState({ kind: 'on' }); + + // Load synced count from disk (no network call at startup) + this._loadFromDisk(); + // Listen to completed OTel spans — deferred off the callback spanListenerStore.add(this._otelService.onDidCompleteSpan(span => { queueMicrotask(() => this._handleSpan(span)); @@ -154,7 +304,6 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._flushBatch().catch(() => { /* best effort */ }); } - this._cloudSessions.clear(); this._translationStates.clear(); this._disabledSessions.clear(); this._initializingSessions.clear(); @@ -162,6 +311,325 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr super.dispose(); } + // ── Session sync suggestion ────────────────────────────────────────────────── + + private _suggestSessionSync(): void { + if (this._syncSuggestionShown) { + return; + } + // Only suggest when local index is on but session sync is off + const localEnabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); + if (!localEnabled || this._configService.getNonExtensionConfig('chat.sessionSync.enabled')) { + return; + } + this._syncSuggestionShown = true; + + vscode.window.showInformationMessage( + vscode.l10n.t('Enable session sync for richer cross-device chat session history.'), + vscode.l10n.t('Enable'), + vscode.l10n.t('Don\'t Show Again'), + ).then(choice => { + if (choice === vscode.l10n.t('Enable')) { + vscode.commands.executeCommand('workbench.action.openSettings', 'chat.sessionSync.enabled'); + } + }); + } + + // ── Delete sessions (Command Palette) ─────────────────────────────────────── + + private async _deleteCloudSessions(): Promise { + type SessionQuickPickItem = vscode.QuickPickItem & { sessionId: string }; + const selectAllId = '__all__'; + + // Show quick pick immediately with loading spinner + const quickPick = vscode.window.createQuickPick(); + quickPick.title = vscode.l10n.t('Delete Cloud Session Data'); + quickPick.placeholder = vscode.l10n.t('Loading sessions...'); + quickPick.canSelectMany = true; + quickPick.busy = true; + quickPick.show(); + + // Reconcile with cloud (lazy, once per window) + await this._reconcileWithCloud(); + + if (this._cloudSessions.size === 0) { + quickPick.dispose(); + vscode.window.showInformationMessage(vscode.l10n.t('No cloud-synced sessions found.')); + return; + } + + // Query local SQLite store for session labels, filtered to cloud-synced sessions only + let rows: Array<{ id: string; repository?: string; created_at?: string; first_message?: string }> = []; + try { + const allRows = this._sessionStore.executeReadOnlyFallback( + `SELECT s.id, s.repository, s.created_at, + (SELECT user_message FROM turns WHERE session_id = s.id ORDER BY turn_index LIMIT 1) as first_message + FROM sessions s ORDER BY s.updated_at DESC LIMIT 500` + ) as Array<{ id: string; repository?: string; created_at?: string; first_message?: string }>; + rows = allRows.filter(row => this._cloudSessions.has(row.id)); + } catch { + // SQLite may be disabled + } + + if (rows.length === 0) { + quickPick.dispose(); + vscode.window.showInformationMessage(vscode.l10n.t('No cloud-synced sessions found locally.')); + return; + } + + // Populate quick pick with items + quickPick.busy = false; + quickPick.placeholder = vscode.l10n.t('Select sessions to delete'); + quickPick.items = [ + { label: '$(checklist) ' + vscode.l10n.t('Select All ({0} sessions)', rows.length), sessionId: selectAllId }, + ...rows.map(row => { + const label = row.first_message + ? row.first_message.length > 60 ? row.first_message.substring(0, 60) + '...' : row.first_message + : row.id.substring(0, 8); + const description = [ + row.repository, + row.created_at ? new Date(row.created_at).toLocaleString() : undefined, + ].filter(Boolean).join(' · '); + return { label, description, sessionId: row.id }; + }), + ]; + + // Wait for user selection + const picked = await new Promise(resolve => { + quickPick.onDidAccept(() => { + resolve([...quickPick.selectedItems]); + quickPick.dispose(); + }); + quickPick.onDidHide(() => { + resolve(undefined); + quickPick.dispose(); + }); + }); + + if (!picked || picked.length === 0) { + return; + } + + // If "Select All" is checked, delete all sessions + const sessionsToDelete = picked.some(p => p.sessionId === selectAllId) + ? rows + : picked.map(p => rows.find(r => r.id === p.sessionId)!).filter(Boolean); + + // Ask where to delete from + type ScopeQuickPickItem = vscode.QuickPickItem & { deleteLocal: boolean }; + const scopeItems: ScopeQuickPickItem[] = [ + { label: vscode.l10n.t('Delete from local and cloud'), description: vscode.l10n.t('Remove from local storage and the cloud'), deleteLocal: true }, + { label: vscode.l10n.t('Delete from Cloud Only'), description: vscode.l10n.t('Keep local data, remove from the cloud'), deleteLocal: false }, + ]; + const scopePick = await vscode.window.showQuickPick(scopeItems, { + title: vscode.l10n.t('Where to Delete From?'), + placeHolder: vscode.l10n.t('Choose deletion scope'), + }); + + if (!scopePick) { + return; + } + + const deleteLocal = scopePick.deleteLocal; + + // Confirmation + const confirmMessage = sessionsToDelete.length === 1 + ? vscode.l10n.t('Are you sure you want to delete this session?') + : vscode.l10n.t('Are you sure you want to delete {0} sessions?', sessionsToDelete.length); + const confirmDetail = deleteLocal + ? vscode.l10n.t('This will delete session data locally and from the cloud. This action cannot be undone.') + : vscode.l10n.t('This will delete session data from the cloud only. Local data will be kept. This action cannot be undone.'); + + const confirm = await vscode.window.showWarningMessage( + confirmMessage, + { modal: true, detail: confirmDetail }, + vscode.l10n.t('Delete'), + ); + + if (confirm !== vscode.l10n.t('Delete')) { + return; + } + + // Execute deletions + let localDeleted = 0; + let cloudDeleted = 0; + let cloudErrors = 0; + + this._setSyncState({ kind: 'deleting', sessionCount: sessionsToDelete.length }); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Deleting sessions...') }, + async () => { + for (const session of sessionsToDelete) { + // Delete locally when scope is "everywhere" + if (deleteLocal) { + try { + this._sessionStore.deleteSession(session.id); + localDeleted++; + } catch { + // Best effort — SQLite may be disabled + } + } + + // Delete from cloud using the stored cloud session ID + const cached = this._cloudSessions.get(session.id); + if (cached) { + const result = await this._cloudClient.deleteSession(cached.cloudSessionId); + switch (result) { + case 'deleted': cloudDeleted++; break; + case 'not_found': cloudDeleted++; break; // Already gone — count as success + case 'error': cloudErrors++; break; + } + } + + // Remove from caches and persisted store + this._cloudSessions.delete(session.id); + this._translationStates.delete(session.id); + this._disabledSessions.delete(session.id); + } + }, + ); + + this._invalidateLocalSyncedCount(); + + // Build result message + const parts: string[] = []; + if (deleteLocal) { + parts.push(vscode.l10n.t('{0} deleted locally', localDeleted)); + } + if (cloudDeleted > 0) { + parts.push(vscode.l10n.t('{0} deleted from cloud', cloudDeleted)); + } + + if (cloudErrors > 0) { + vscode.window.showWarningMessage(parts.join(', ') + '. ' + vscode.l10n.t('{0} cloud deletion(s) failed.', cloudErrors)); + this._setSyncState({ kind: 'error' }); + } else { + vscode.window.showInformationMessage(parts.join(', ') + '.'); + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + } + + this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { + operation: 'deleteSessions', + source: 'commandPalette', + }, { + totalRequested: sessionsToDelete.length, + localDeleted, + cloudDeleted, + cloudErrors, + }); + } + + // ── Delete from cloud + local SQLite (called by sessions window delete action) ─ + + /** + * Best-effort cloud and local SQLite deletion for the given session IDs. + * Called from the sessions window right-click delete action — no UI shown. + */ + private async _deleteSessionsFromCloud(sessionIds: string[]): Promise { + if (!sessionIds || sessionIds.length === 0) { + return; + } + + // Ensure cloud session ID store is loaded from disk + await this._cloudSessions.load(); + + const cloudEnabled = this._configService.getNonExtensionConfig('chat.sessionSync.enabled') ?? false; + + for (const sessionId of sessionIds) { + // Delete from local SQLite store + try { + this._sessionStore.deleteSession(sessionId); + } catch { + // Best effort + } + + // Delete from cloud only when session sync is enabled + const wasCloudSynced = this._cloudSessions.has(sessionId); + if (cloudEnabled && wasCloudSynced) { + const cached = this._cloudSessions.get(sessionId)!; + try { + await this._cloudClient.deleteSession(cached.cloudSessionId); + } catch { + // Best effort — don't block the caller + } + } + + // Remove from in-memory caches + this._cloudSessions.delete(sessionId); + this._translationStates.delete(sessionId); + this._disabledSessions.delete(sessionId); + } + this._invalidateLocalSyncedCount(); + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + } + + // ── Cloud reindex (called from /chronicle:reindex) ────────────────────────── + + /** + * Reindex all local sessions to the cloud. Creates cloud sessions for + * any local sessions not yet synced, uploads their events, and triggers + * a bulk analytics backfill. + * + * Returns undefined when cloud reindex is not applicable (cloud disabled, + * no consent, no repo). + */ + private async _reindexCloud( + reportProgress: (msg: string) => void, + token: vscode.CancellationToken, + ): Promise { + const cloudEnabled = this._configService.getNonExtensionConfig('chat.sessionSync.enabled') ?? false; + if (!cloudEnabled) { + return undefined; + } + + // Reconcile with cloud to know which sessions already exist (lazy, once per window) + await this._reconcileWithCloud(); + + const repo = await this._resolveRepository(); + if (!repo) { + return undefined; + } + + const repoNwo = `${repo.owner}/${repo.repo}`; + if (!this._indexingPreference.hasCloudConsent(repoNwo)) { + return undefined; + } + + const indexingLevel = this._indexingPreference.getStorageLevel(repoNwo); + if (indexingLevel === 'local') { + return undefined; + } + + const cloudIndexingLevel = indexingLevel === 'repo_and_user' ? 'repo_and_user' as const : 'user' as const; + + const result = await reindexCloudSessions( + this._cloudClient, + this._cloudSessions, + this._debugLogService, + repo.repoIds.ownerId, + repo.repoIds.repoId, + cloudIndexingLevel, + reportProgress, + token, + nwo => !this._indexingPreference.hasCloudConsent(nwo), + ); + + // Update sync state with new count + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + + this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { + operation: 'reindex', + }, { + created: result.created, + failed: result.failed, + eventsUploaded: result.eventsUploaded, + backfillQueued: result.backfillQueued, + }); + + return result; + } + // ── Span handling ──────────────────────────────────────────────────────────── private _handleSpan(span: ICompletedSpanData): void { @@ -384,6 +852,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr }; this._cloudSessions.set(sessionId, cloudIds); + this._invalidateLocalSyncedCount(); this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'createCloudSession', @@ -446,7 +915,8 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._bufferEvents(sessionId, [event]); } - this._cloudSessions.delete(sessionId); + // Keep _cloudSessions entry — the cloud session ID mapping is needed + // for future delete operations (e.g. sidebar delete fires after dispose). this._translationStates.delete(sessionId); this._disabledSessions.delete(sessionId); this._initializingSessions.delete(sessionId); @@ -527,6 +997,8 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._isFlushing = true; const batch = this._eventBuffer.splice(0, MAX_EVENTS_PER_FLUSH); const batchStart = Date.now(); + const uniqueSessionsInBatch = new Set(batch.map(e => e.chatSessionId)).size; + this._setSyncState({ kind: 'syncing', sessionCount: uniqueSessionsInBatch }); try { // Group events by chat session ID for correct cloud session routing @@ -590,6 +1062,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } } else if (!allSuccess) { this._circuitBreaker.recordFailure(); + this._setSyncState({ kind: 'error' }); this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'circuitBreaker', @@ -601,6 +1074,10 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr bufferSize: this._eventBuffer.length, }); } + + if (allSuccess) { + this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); + } } catch (err) { // Re-queue on unexpected error this._eventBuffer.unshift(...batch); @@ -611,6 +1088,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr success: 'false', error: err instanceof Error ? err.message.substring(0, 100) : 'unknown', }, { droppedEvents: batch.length }); + this._setSyncState({ kind: 'error' }); } finally { this._isFlushing = false; } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionSync.contribution.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSync.contribution.ts new file mode 100644 index 0000000000000..75977a34de929 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSync.contribution.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle'; +import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { RemoteSessionExporter } from './remoteSessionExporter'; +import { SessionSyncStatus } from './sessionSyncStatus'; + +export function create(accessor: ServicesAccessor): IDisposable { + const instantiationService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); + const expService = accessor.get(IExperimentationService); + + const disposableStore = new DisposableStore(); + + // Create the exporter (manages cloud sync + state) + const exporter = instantiationService.createInstance(RemoteSessionExporter); + disposableStore.add(exporter); + + // Create the status item (renders state in the chat status bar popup) + const statusItem = new SessionSyncStatus(exporter, configService, expService); + disposableStore.add(statusItem); + + return disposableStore; +} diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts new file mode 100644 index 0000000000000..7dded8ff7cf15 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as l10n from '@vscode/l10n'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { ISessionSyncStateService, type SessionSyncState } from '../common/sessionSyncStateService'; + +const statusTitle = l10n.t('Session Sync'); +const sessionSyncDocsLink = 'https://aka.ms/vscode-copilot-session-sync'; + +/** + * Shows session sync status in the chat status bar popup. + * + * Renders a contributed chat status item that displays the current + * cloud sync state — not enabled, on, syncing, up to date, error, etc. + * Follows the same pattern as ChatStatusWorkspaceIndexingStatus. + */ +export class SessionSyncStatus extends Disposable { + + private readonly _statusItem: vscode.ChatStatusItem; + + constructor( + private readonly _syncStateService: ISessionSyncStateService, + private readonly _configService: IConfigurationService, + private readonly _expService: IExperimentationService, + ) { + super(); + + this._statusItem = this._register(vscode.window.createChatStatusItem('copilot.sessionSyncStatus')); + this._statusItem.title = statusTitle; + + // Listen for sync state changes + this._register(this._syncStateService.onDidChangeSyncState(state => this._renderState(state))); + + // Listen for config changes to show/hide + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('chat.localIndex.enabled')) { + this._updateVisibility(); + } + })); + + this._updateVisibility(); + this._renderState(this._syncStateService.syncState); + } + + private _updateVisibility(): void { + const localEnabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); + if (!localEnabled) { + this._statusItem.hide(); + } else { + this._statusItem.show(); + this._renderState(this._syncStateService.syncState); + } + } + + private _renderState(state: SessionSyncState): void { + // Don't render if localIndex is off — item should stay hidden + const localEnabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); + if (!localEnabled) { + return; + } + + this._statusItem.title = { + label: statusTitle, + link: sessionSyncDocsLink, + helpText: l10n.t('Syncs session data to your GitHub.com account.'), + }; + + // description → shown as badge in collapsed header (icon + message) + // detail → shown when expanded + const tipsAction = `[${l10n.t('Get Tips from Sessions')}](command:workbench.action.chat.open?%7B%22query%22%3A%22%2Fchronicle%3Atips%22%7D)`; + + switch (state.kind) { + case 'not-enabled': + this._statusItem.description = `$(circle-slash) ${l10n.t('Not enabled')}`; + this._statusItem.detail = `[${l10n.t('Enable Session Sync')}](command:workbench.action.openSettings?%5B%22chat.sessionSync.enabled%22%5D)`; + break; + + case 'disabled-by-policy': + this._statusItem.description = `$(warning) ${l10n.t('Disabled by policy')}`; + this._statusItem.detail = l10n.t('Session sync is disabled by your organization\'s policy.'); + break; + + case 'on': + this._statusItem.description = `$(check) ${l10n.t('On')}`; + this._statusItem.detail = tipsAction; + break; + + case 'syncing': + this._statusItem.description = `${l10n.t('Syncing {0} session(s)\u2026', state.sessionCount)} $(loading~spin)`; + this._statusItem.detail = tipsAction; + break; + + case 'up-to-date': + this._statusItem.description = `$(check) ${l10n.t('{0} sessions synced', state.syncedCount)}`; + this._statusItem.detail = tipsAction; + break; + + case 'deleting': + this._statusItem.description = `${l10n.t('Deleting {0} session(s)\u2026', state.sessionCount)} $(loading~spin)`; + this._statusItem.detail = tipsAction; + break; + + case 'error': + this._statusItem.description = `$(warning) ${l10n.t('Sync failed')}`; + this._statusItem.detail = tipsAction; + break; + } + } +} diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 2dac4f5e30021..9d1895e1e99aa 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -10,8 +10,8 @@ import { ChatDebugFileLoggerContribution } from '../../chat/vscode-node/chatDebu import { ChatQuotaContribution } from '../../chat/vscode-node/chatQuota.contribution'; import { ChatSessionContextContribution } from '../../chatSessionContext/vscode-node/chatSessionContextProvider'; import { ChatSessionsContrib } from '../../chatSessions/vscode-node/chatSessions'; -import { RemoteSessionExporter } from '../../chronicle/vscode-node/remoteSessionExporter'; import { SessionStoreTracker } from '../../chronicle/vscode-node/sessionStoreTracker'; +import * as sessionSyncContribution from '../../chronicle/vscode-node/sessionSync.contribution'; import * as chatBlockLanguageContribution from '../../codeBlocks/vscode-node/chatBlockLanguageFeatures.contribution'; import { IExtensionContributionFactory, asContributionFactory } from '../../common/contributions'; import { CompletionsUnificationContribution } from '../../completions/vscode-node/completionsUnificationContribution'; @@ -103,7 +103,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(GitHubMcpContrib), asContributionFactory(OTelContrib), asContributionFactory(SessionStoreTracker), - asContributionFactory(RemoteSessionExporter), + sessionSyncContribution, ]; /** diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts index 5da34da7d0cda..b3c4ac495c086 100644 --- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts +++ b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts @@ -23,6 +23,7 @@ import { SessionIndexingPreference } from '../../chronicle/common/sessionIndexin import { CloudSessionStoreClient } from '../../chronicle/node/cloudSessionStoreClient'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; import { IToolsService } from '../../tools/common/toolsService'; import { ToolName } from '../../tools/common/toolNames'; import { Conversation } from '../../prompt/common/conversation'; @@ -51,7 +52,8 @@ export class ChronicleIntent implements IIntent { readonly id = ChronicleIntent.ID; readonly description = l10n.t('Session history tools and insights (standup, tips, improve)'); get locations(): ChatLocation[] { - return this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; + const enabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); + return enabled ? [ChatLocation.Panel] : []; } readonly commandInfo: IIntentSlashCommandInfo = { @@ -69,6 +71,7 @@ export class ChronicleIntent implements IIntent { @ITelemetryService private readonly _telemetryService: ITelemetryService, @IExperimentationService private readonly _expService: IExperimentationService, @IFetcherService private readonly _fetcherService: IFetcherService, + @IRunCommandExecutionService private readonly _commandService: IRunCommandExecutionService, @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, ) { this._indexingPreference = new SessionIndexingPreference(this._configService); @@ -89,11 +92,15 @@ export class ChronicleIntent implements IIntent { location: ChatLocation, chatTelemetry: ChatTelemetryBuilder, ): Promise { - if (!this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService)) { + const localEnabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); + if (!localEnabled) { stream.markdown(l10n.t('Session search is not available yet.')); return {}; } + // Nudge user to enable session sync (non-blocking, once per session) + this._commandService.executeCommand('github.copilot.sessionSync.suggest').catch(() => { /* command not available */ }); + // Route by command name (e.g. 'chronicle:standup') or fall back to parsing the prompt const { subcommand, rest } = this._resolveSubcommand(request); @@ -168,7 +175,7 @@ export class ChronicleIntent implements IIntent { if (result.cancelled) { lines.push(l10n.t('Reindex cancelled.')); } else { - lines.push(l10n.t('Reindex complete.')); + lines.push(l10n.t('Local reindex complete.')); } lines.push(''); @@ -183,6 +190,41 @@ export class ChronicleIntent implements IIntent { stream.markdown(lines.join('\n')); + // ── Cloud reindex phase ───────────────────────────────────────── + // Runs after local reindex, gated by the reindex command in RemoteSessionExporter + // which checks cloud sync enabled + consent + repo. + let cloudSessionCount = 0; + if (!result.cancelled && !token.isCancellationRequested) { + try { + stream.progress(l10n.t('Starting cloud session sync...')); + const cloudResult = await this._commandService.executeCommand( + 'github.copilot.sessionSync.reindex', + (msg: string) => stream.progress(msg), + token, + ) as { created: number; eventsUploaded: number; failed: number; backfillQueued: number; backfillFailed?: boolean } | undefined; + + if (cloudResult) { + cloudSessionCount = cloudResult.created; + const cloudLines: string[] = []; + if (cloudResult.created > 0 || cloudResult.eventsUploaded > 0) { + cloudLines.push(''); + cloudLines.push(l10n.t('**Cloud sync:** {0} session(s) created, {1} event(s) uploaded.', cloudResult.created, cloudResult.eventsUploaded)); + } + if (cloudResult.failed > 0) { + cloudLines.push(l10n.t('⚠ {0} session(s) failed cloud sync.', cloudResult.failed)); + } + if (cloudResult.backfillFailed) { + cloudLines.push(l10n.t('⚠ Cloud indexing request failed.')); + } + if (cloudLines.length > 0) { + stream.markdown(cloudLines.join('\n')); + } + } + } catch { + // Cloud phase failure is non-fatal — local reindex already succeeded + } + } + const durationMs = Date.now() - startTime; /* __GDPR__ "chronicle.reindex" : { @@ -194,6 +236,7 @@ export class ChronicleIntent implements IIntent { "processed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Sessions successfully reindexed." }, "skipped": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Sessions skipped (already indexed)." }, "totalSessions": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total session count on disk." }, + "cloudSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Sessions created in cloud during reindex." }, "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total reindex duration in ms." } } */ @@ -205,6 +248,7 @@ export class ChronicleIntent implements IIntent { processed: result.processed, skipped: result.skipped, totalSessions: result.processed + result.skipped, + cloudSessionCount, durationMs, }); diff --git a/extensions/copilot/src/platform/chronicle/common/sessionStore.ts b/extensions/copilot/src/platform/chronicle/common/sessionStore.ts index a1881ae7aea77..8bdd168ce5292 100644 --- a/extensions/copilot/src/platform/chronicle/common/sessionStore.ts +++ b/extensions/copilot/src/platform/chronicle/common/sessionStore.ts @@ -114,6 +114,9 @@ export interface ISessionStore { /** Index a workspace artifact for full-text search. Upserts by file path. */ indexWorkspaceArtifact(sessionId: string, filePath: string, content: string): void; + /** Delete a session and all associated data (turns, checkpoints, files, refs, search index). */ + deleteSession(sessionId: string): void; + // ── Queries ───────────────────────────────────────────────────────── /** Full-text search across all indexed content. */ diff --git a/extensions/copilot/src/platform/chronicle/node/sessionStore.ts b/extensions/copilot/src/platform/chronicle/node/sessionStore.ts index 2348602da2fbc..d50feb6fe37e5 100644 --- a/extensions/copilot/src/platform/chronicle/node/sessionStore.ts +++ b/extensions/copilot/src/platform/chronicle/node/sessionStore.ts @@ -386,6 +386,22 @@ export class SessionStore implements ISessionStore { ); } + /** + * Delete a session and all associated data. + * Removes turns, checkpoints, files, refs, search index entries, and the session row. + */ + deleteSession(sessionId: string): void { + const db = this.ensureDb(); + this.runInTransaction(() => { + db.prepare('DELETE FROM search_index WHERE session_id = ?').run(sessionId); + db.prepare('DELETE FROM session_refs WHERE session_id = ?').run(sessionId); + db.prepare('DELETE FROM session_files WHERE session_id = ?').run(sessionId); + db.prepare('DELETE FROM checkpoints WHERE session_id = ?').run(sessionId); + db.prepare('DELETE FROM turns WHERE session_id = ?').run(sessionId); + db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); + }); + } + /** * Full-text search across all indexed content (turns, checkpoint sections, and workspace artifacts). * Uses FTS5 MATCH with BM25 ranking. diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index da43538297789..a0ad29c56ba4f 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -892,11 +892,6 @@ export namespace ConfigKey { /** Enable WebSocket transport for Responses API requests. When enabled, uses a persistent WebSocket connection per conversation instead of individual HTTP requests. */ export const ResponsesApiWebSocketEnabled = defineTeamInternalSetting('chat.advanced.responsesApi.webSocket.enabled', ConfigType.ExperimentBased, true); export const DebugSimulateWebSocketResponse = defineTeamInternalSetting('chat.advanced.debug.simulateWebSocketResponse', ConfigType.Simple, ''); - - /** Enable cloud sync of session data to cloud. */ - export const SessionSearchCloudSyncEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.cloudSync.enabled', ConfigType.Simple, false, vBoolean()); - /** Repository patterns to exclude from cloud sync (exact owner/repo or glob patterns like my-org/*). */ - export const SessionSearchCloudSyncExcludeRepositories = defineTeamInternalSetting('chat.advanced.sessionSearch.cloudSync.excludeRepositories', ConfigType.Simple, []); } /** diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 31aa3b97b032a..ed92b0514f9f4 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -41,10 +41,19 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { }; } +export const enum CopilotSessionSearchPolicy { + Unknown = 0, + Enabled = 1, + Disabled = 2, + Unconfigured = 3, + NoPolicy = 4, +} + export interface IPolicyData { readonly mcp?: boolean; readonly chat_preview_features_enabled?: boolean; readonly chat_agent_enabled?: boolean; + readonly session_search?: CopilotSessionSearchPolicy; readonly mcpRegistryUrl?: string; readonly mcpAccess?: 'allow_all' | 'registry_only'; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 61ef7f4486570..0ed5bf27a2f66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -16,7 +16,7 @@ import { IAgentSessionsService, AgentSessionsService } from './agentSessionsServ import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction, CollapseAllAgentSessionSectionsAction } from './agentSessionsActions.js'; +import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAgentSessionInlineAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction, CollapseAllAgentSessionSectionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; //#region Actions and Menus @@ -35,6 +35,7 @@ registerAction2(PinAgentSessionAction); registerAction2(UnpinAgentSessionAction); registerAction2(RenameAgentSessionAction); registerAction2(DeleteAgentSessionAction); +registerAction2(DeleteAgentSessionInlineAction); registerAction2(DeleteAllLocalSessionsAction); registerAction2(MarkAgentSessionUnreadAction); registerAction2(MarkAgentSessionReadAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 57edb5acf1384..ac9a17f47e772 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -11,6 +11,7 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, AgentSessionProviders, AgentSessionsViewerOrientation, IAgentSessionsControl } from './agentSessions.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, PreferredGroup, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; @@ -689,6 +690,7 @@ export class DeleteAgentSessionAction extends BaseAgentSessionAction { const chatService = accessor.get(IChatService); const dialogService = accessor.get(IDialogService); const widgetService = accessor.get(IChatWidgetService); + const commandService = accessor.get(ICommandService); const confirmed = await dialogService.confirm({ message: sessions.length === 1 @@ -702,6 +704,8 @@ export class DeleteAgentSessionAction extends BaseAgentSessionAction { return; } + const deletedSessionIds: string[] = []; + for (const session of sessions) { // Clear chat widget @@ -709,6 +713,61 @@ export class DeleteAgentSessionAction extends BaseAgentSessionAction { // Remove from storage await chatService.removeHistoryEntry(session.resource); + + // Track session ID for cloud cleanup + const sessionId = LocalChatSessionUri.parseLocalSessionId(session.resource); + if (sessionId) { + deletedSessionIds.push(sessionId); + } + } + + // Notify extensions to clean up cloud data (best effort) + if (deletedSessionIds.length > 0) { + commandService.executeCommand('github.copilot.sessionSync.deleteSessionFromCloud', deletedSessionIds).catch(() => { /* best effort */ }); + } + } +} + +export class DeleteAgentSessionInlineAction extends BaseAgentSessionAction { + + constructor() { + super({ + id: 'agentSession.deleteInline', + title: localize2('del', "Del"), + icon: Codicon.trash, + menu: { + id: MenuId.AgentSessionItemToolbar, + group: 'navigation', + order: 2, + when: ChatContextKeys.agentSessionType.isEqualTo(AgentSessionProviders.Local) + } + }); + } + + async runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise { + if (sessions.length === 0) { + return; + } + + const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); + const commandService = accessor.get(ICommandService); + + const deletedSessionIds: string[] = []; + + for (const session of sessions) { + await widgetService.getWidgetBySessionResource(session.resource)?.clear(); + await chatService.removeHistoryEntry(session.resource); + + const sessionId = LocalChatSessionUri.parseLocalSessionId(session.resource); + if (sessionId) { + deletedSessionIds.push(sessionId); + } + } + + // Notify extensions to clean up cloud data (best effort) + if (deletedSessionIds.length > 0) { + commandService.executeCommand('github.copilot.sessionSync.deleteSessionFromCloud', deletedSessionIds).catch(() => { /* best effort */ }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 32af65ba2ed8a..1e475f4a1d4ff 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -8,6 +8,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/com import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; +import { CopilotSessionSearchPolicy } from '../../../../base/common/defaultAccount.js'; import { AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; @@ -486,6 +487,31 @@ configurationRegistry.registerConfiguration({ }, } }, + [ChatConfiguration.SessionSyncEnabled]: { + default: false, + markdownDescription: nls.localize('chat.sessionSync.enabled', "Enable session sync to GitHub.com. When enabled, Copilot session data is synced to your GitHub account for cross-device access and richer insights. Requires local session tracking to also be enabled."), + type: 'boolean', + tags: ['experimental', 'advanced'], + policy: { + name: 'CopilotSessionSync', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.119', + value: (policyData) => policyData.session_search === CopilotSessionSearchPolicy.Disabled ? false : undefined, + localization: { + description: { + key: 'chat.sessionSync.enabled.policy', + value: nls.localize('chat.sessionSync.enabled.policy', "Enable session sync to GitHub.com for cross-device Copilot session history. When disabled by organization policy, session data is kept local only."), + } + }, + } + }, + [ChatConfiguration.SessionSyncExcludeRepositories]: { + type: 'array', + items: { type: 'string' }, + default: [], + markdownDescription: nls.localize('chat.sessionSync.excludeRepositories', "Repository patterns to exclude from session sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repositories will only be stored locally."), + tags: ['experimental', 'advanced'], + }, [ChatConfiguration.AutoApproveEdits]: { default: { '**/*': true, diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 901af5e8efcf5..e36be613ce668 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -106,6 +106,7 @@ gap: 10px; margin-top: 10px; margin-bottom: 4px; + padding-left: 24px; } .chat-status-bar-entry-tooltip .collapsible-content.collapsed > .collapsible-inner { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 43d8f32ad513d..e3a659093bbbb 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -46,6 +46,8 @@ export enum ChatConfiguration { NotifyWindowOnConfirmation = 'chat.notifyWindowOnConfirmation', NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived', ChatViewSessionsEnabled = 'chat.viewSessions.enabled', + SessionSyncEnabled = 'chat.sessionSync.enabled', + SessionSyncExcludeRepositories = 'chat.sessionSync.excludeRepositories', ChatViewSessionsGrouping = 'chat.viewSessions.grouping', ChatViewSessionsOrientation = 'chat.viewSessions.orientation', ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 7deab625bbfb3..2ed8660ad2c09 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -6,7 +6,7 @@ import { distinct } from '../../../../base/common/arrays.js'; import { Barrier, RunOnceScheduler, ThrottledDelayer, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData, IPolicyData } from '../../../../base/common/defaultAccount.js'; +import { CopilotSessionSearchPolicy, ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData, IPolicyData } from '../../../../base/common/defaultAccount.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -549,6 +549,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.policyData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.policyData.chat_preview_features_enabled; + policyData.session_search = tokenEntitlementsData.policyData.session_search; policyData.mcp = tokenEntitlementsData.policyData.mcp; if (policyData.mcp) { const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData, options); @@ -670,6 +671,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid chat_agent_enabled: tokenMap.get('agent_mode') !== '0', // MCP is only enabled if the flag is explicitly present and set to 1 mcp: tokenMap.get('mcp') === '1', + // Session search policy enum from Copilot token + session_search: Number(tokenMap.get('session_search') ?? '0') as CopilotSessionSearchPolicy, }, copilotTokenInfo: { sn: tokenMap.get('sn'), diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index 6af55b8bf7caf..fa7b0f2cac501 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; +import { CopilotSessionSearchPolicy, IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; import { Event } from '../../../../../base/common/event.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -125,7 +125,18 @@ suite('MultiplexPolicyService', () => { 'setting.E': { 'type': 'boolean', 'default': true, - } + }, + 'setting.F': { + 'type': 'boolean', + 'default': true, + policy: { + name: 'PolicySettingF', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + value: policyData => policyData.session_search === CopilotSessionSearchPolicy.Disabled ? false : undefined, + } + }, } }; @@ -312,4 +323,54 @@ suite('MultiplexPolicyService', () => { assert.strictEqual(D, false); } }); + + test('session_search policy disabled overrides setting', async () => { + await clear(); + + const policyData: IPolicyData = { session_search: CopilotSessionSearchPolicy.Disabled }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); + assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.F'), false); + }); + + test('session_search policy enabled does not override setting', async () => { + await clear(); + + const policyData: IPolicyData = { session_search: CopilotSessionSearchPolicy.Enabled }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.F'), undefined); + }); + + test('session_search policy with no opinion values does not override setting', async () => { + await clear(); + + for (const value of [CopilotSessionSearchPolicy.Unknown, CopilotSessionSearchPolicy.Unconfigured, CopilotSessionSearchPolicy.NoPolicy]) { + const policyData: IPolicyData = { session_search: value }; + defaultAccountService = disposables.add(new DefaultAccountService(TestProductService)); + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); + await defaultAccountService.refresh(); + + policyService = disposables.add(new MultiplexPolicyService([ + disposables.add(new FilePolicyService(policyFile, fileService, new NullLogService())), + disposables.add(new AccountPolicyService(logService, defaultAccountService)), + ], logService)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined, `Expected undefined for CopilotSessionSearchPolicy value ${value}`); + assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.F'), undefined, `Expected undefined for CopilotSessionSearchPolicy value ${value}`); + } + }); });