diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 4c5d01a88a8647..6a0749e712110c 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 13aa9196254cab..0dcfc623a552cb 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 44adc44eb8c7b5..abc75223e7547c 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/chat/vscode-node/chatDebugFileLoggerService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts index e2e94a2c0d3e0b..d868c34e9d2eb6 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/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index d53091a1dd6d30..cc3361535d591f 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)); } diff --git a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts index a63023a0950fe0..3b14244c2acdae 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 c7d3d2de9605e9..a755e71490167d 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 00000000000000..67f7d9437bc73c --- /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 d863aa7da9a2c8..77a06a4de753b8 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 82568f03d9dd0d..e5529f88a6bffe 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 7c0268d0ba5efc..d51979d0c4442a 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 00000000000000..ab2b5b9b13a4e9 --- /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 72b3743238387b..3ef024a7d5fb55 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 00000000000000..3fa64ab6d96872 --- /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 fcacbd95d9f740..9a84aaeb55bdc1 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 240b8f4947f162..ad3b81f43f39bf 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 00000000000000..75977a34de9291 --- /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 00000000000000..7dded8ff7cf150 --- /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 2dac4f5e300212..9d1895e1e99aa8 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 5da34da7d0cda1..b3c4ac495c0867 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/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 7f70df38ce8705..5a6b63d2df1d6e 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/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index fcac1ac97541a9..67d95b8445a26b 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 ); } diff --git a/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts b/extensions/copilot/src/extension/trajectory/vscode-node/otelSpanToChatDebugEvent.ts index 901c6ab3b0896b..fadd50e6ec90fe 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/chronicle/common/sessionStore.ts b/extensions/copilot/src/platform/chronicle/common/sessionStore.ts index a1881ae7aea775..8bdd168ce52923 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 2348602da2fbca..d50feb6fe37e5e 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 da435382977899..a0ad29c56ba4f7 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/extensions/copilot/src/platform/otel/common/genAiAttributes.ts b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts index c922112966d7ca..54acb82775ca7a 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/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index a9de8b9604150b..63617916b30053 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/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index c60093dbfaecb6..0c35378cfa3f04 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 95409bc8e65f32..474276171b7701 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", diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 31aa3b97b032ad..ed92b0514f9f4e 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/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index ed2e160ddc7657..0fa89cecd9dab8 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 fc7504a2801325..d426f2b2c0d2c2 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 58fe04319768f4..6a7dc566bb6fdb 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 5c56631630029a..0534484861eb5c 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 248a38a89f3e5b..0a6edebac672d5 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 b0807afae93ea1..9f30d5f6e372d8 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/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 3c8407d3307849..1d6d8b4448d98b 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 afc3121f922f1b..9d9a69923ed636 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/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 7b7992881934cd..4ddf54c2896e15 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 eb643c787963e2..bb77d64ae03002 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 () => { diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index c858917a4dbec6..5281b3a786f531 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/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 09acdfa10143d7..8aad081c4d8684 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/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8d7bbef25c08ae..8282d175cde94f 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 a7dbcc72670ffb..a32b21525837a4 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/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 7d0cd091eae0ce..37b93c35b49023 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/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 34fa2025b7f77d..c58430ade8f21a 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/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 7c8fdbf44beaa7..1e8e77248c10e2 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 ?? '' }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 92d03344044d4a..39e4afbce1fd54 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 c20cfa590d6f70..215f9fac15d62f 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/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 61ef7f4486570a..0ed5bf27a2f66e 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 57edb5acf13844..ac9a17f47e7720 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/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 52b0707f567ba0..f4faa80531e993 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/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 32af65ba2ed8a1..1e475f4a1d4ffa 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/chatDebug/chatDebugCacheDiff.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheDiff.ts new file mode 100644 index 00000000000000..2571c02d4c0701 --- /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 00000000000000..bd566ba904a436 --- /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 67f5d8de3b0dc4..231ab36af2edc8 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 e6ff9e8cd76076..9880f2c6b22736 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 4420b44fe7e0eb..701ed61ed447ba 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 62f14be344ccd8..37096436877d5c 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 a6ac1bc9799724..8590fdae4690aa 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 a38b0f0d6664b4..df50e251d5ee06 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/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index aeb355759dfc0f..4f7446e4736b14 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 089c3035e2b8fb..c9ed88f7342470 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'; @@ -53,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', @@ -1035,7 +1035,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 +1048,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 +1090,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/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 901af5e8efcf5c..e36be613ce668f 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/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 66535a8f2927df..79ed08b2398f79 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; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 411ea50f3e7f0e..2b57beb4f669fd 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 688c8c6beec6bc..7b8cf8a3766806 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/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index d24d8a392216b9..b5f0b2ae2bab17 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/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 84f6c3b2f8a384..5ddd10f2805c19 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 733c6d88adec34..b2c04973cd21b0 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 352a53bffda2c0..e37726bb9e981f 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/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 43d8f32ad513db..e3a659093bbbbb 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/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 3c3af139a2a3bd..23fb44746437e1 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); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 6d36c65c7f50ef..4ff46168a68643 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; } 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 00000000000000..575dd0083fffe6 --- /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 8d9a7d19bfda14..26a27cdc117db9 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/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index a1cde58a3a4120..3484089980aa4d 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); } diff --git a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts index 9187a0b2688f03..1e029d8945c227 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, diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 7deab625bbfb33..2ed8660ad2c09b 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 6af55b8bf7cafc..fa7b0f2cac501c 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}`); + } + }); }); diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index 0d14ef4504fcb1..f1a4f480ff17e7 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. */