diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index e314bce7a9136b..202bc218180399 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -77,6 +77,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.) - **`NeedsInput` is still an active turn for live turn UI**: agent-host tool and input confirmations intentionally transition a running chat from `InProgress` to `NeedsInput` without ending `activeTurn`. Live status surfaces such as the chat input pills must use `isActiveSessionStatus` so they do not disappear until the next output returns the chat to `InProgress`. - **Agent-host-only exclusions for built-in client tools belong in `ClientToolSetsContribution`, not the global tool registration**: `AgentHostActiveClientService.getClientTools` advertises enabled members of every non-deprecated tool set, including extension-contributed sets. Omit an unsupported built-in tool from the client tool sets so normal Copilot chat can continue using it; do not treat this contribution as the sole Agent Host allowlist. +- **Non-interactive MCP authentication probes must not create dynamic authentication providers**: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With `allowInteraction: false`, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes the `mcpAuthenticationRequired` action. ## Capturing Feedback (meta-rule) diff --git a/build/azure-pipelines/common/smokeTestAgentHost.ts b/build/azure-pipelines/common/smokeTestAgentHost.ts new file mode 100644 index 00000000000000..bb9fd3a888fb61 --- /dev/null +++ b/build/azure-pipelines/common/smokeTestAgentHost.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ChildProcess, fork } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setTimeout as delay } from 'timers/promises'; + +const startupTimeoutMs = 30_000; +const shutdownTimeoutMs = 5_000; +const readyPattern = /Agent host server listening on \S+/; + +async function main(serverRoot: string | undefined): Promise { + if (!serverRoot) { + throw new Error('Usage: node smokeTestAgentHost.ts '); + } + + const nodePath = path.join(serverRoot, process.platform === 'win32' ? 'node.exe' : 'node'); + const bootstrapPath = path.join(serverRoot, 'out', 'bootstrap-fork.js'); + for (const requiredPath of [nodePath, bootstrapPath]) { + if (!fs.existsSync(requiredPath)) { + throw new Error(`Packaged Agent Host dependency not found: ${requiredPath}`); + } + } + + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-agent-host-smoke-')); + const logsPath = path.join(temporaryRoot, 'logs'); + const userDataPath = path.join(temporaryRoot, 'user-data'); + fs.mkdirSync(logsPath, { recursive: true }); + fs.mkdirSync(userDataPath, { recursive: true }); + + const child = fork(bootstrapPath, [ + '--type=agentHost', + '--logsPath', logsPath, + '--user-data-dir', userDataPath, + '--disable-telemetry', + ], { + cwd: serverRoot, + env: { + ...process.env, + VSCODE_DEV: undefined, + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_AGENT_HOST_CONNECTION_TOKEN: undefined, + VSCODE_AGENT_HOST_HOST: '127.0.0.1', + VSCODE_AGENT_HOST_PORT: '0', + VSCODE_AGENT_HOST_SOCKET_PATH: undefined, + VSCODE_NLS_CONFIG: JSON.stringify({ + userLocale: 'en', + osLocale: 'en', + resolvedLanguage: 'en', + defaultMessagesFile: path.join(serverRoot, 'out', 'nls.messages.json'), + }), + VSCODE_PIPE_LOGGING: 'false', + VSCODE_VERBOSE_LOGGING: 'false', + }, + execPath: nodePath, + silent: true, + }); + + try { + await waitForReady(child); + console.log('Packaged Agent Host started successfully.'); + } finally { + await stopProcess(child); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function waitForReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let output = ''; + let settled = false; + + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + const appendOutput = (data: Buffer) => { + const text = data.toString(); + process.stdout.write(text); + output = `${output}${text}`.slice(-64 * 1024); + if (readyPattern.test(output)) { + finish(); + } + }; + + child.stdout?.on('data', appendOutput); + child.stderr?.on('data', appendOutput); + child.once('error', error => finish(error)); + child.once('exit', (code, signal) => { + finish(new Error(`Packaged Agent Host exited before becoming ready (code: ${code}, signal: ${signal}).\n${output}`)); + }); + + const timeout = setTimeout(() => { + finish(new Error(`Timed out after ${startupTimeoutMs}ms waiting for the packaged Agent Host to start.\n${output}`)); + }, startupTimeoutMs); + }); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + const exited = new Promise(resolve => child.once('exit', () => resolve())); + child.kill(); + await Promise.race([exited, delay(shutdownTimeoutMs)]); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await exited; + } +} + +main(process.argv[2]).catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 3b1d0b2f8feb3d..39f1c1cffd8a1c 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -200,6 +200,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + - script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - script: | set -e npm run gulp vscode-reh-web-darwin-$(VSCODE_ARCH)-min-ci diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 620ef2088c65a1..716983d22b5043 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -290,6 +290,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - script: | set -e npm run gulp vscode-reh-web-linux-$(VSCODE_ARCH)-min-ci diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index f61f9fc904a3f2..44248830c94854 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -229,6 +229,10 @@ steps: AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - powershell: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)\vscode-server-win32-$(VSCODE_ARCH)" + displayName: 🧪 Smoke test packaged Agent Host + - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" diff --git a/build/lib/test/agentHostDependencies.test.ts b/build/lib/test/agentHostDependencies.test.ts new file mode 100644 index 00000000000000..b0731de0e52f07 --- /dev/null +++ b/build/lib/test/agentHostDependencies.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import { builtinModules } from 'module'; +import path from 'path'; +import { suite, test } from 'node:test'; +import * as ts from 'typescript'; + +const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); +const agentHostEntryPoints = [ + 'src/vs/platform/agentHost/node/agentHostMain.ts', + 'src/vs/platform/agentHost/node/agentHostServerMain.ts', +]; + +suite('Agent Host dependencies', () => { + test('runtime packages are included in the remote server', () => { + const remotePackageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, 'remote/package.json'), 'utf8')) as { + dependencies?: Record; + optionalDependencies?: Record; + }; + const packagedDependencies = new Set([ + ...Object.keys(remotePackageJson.dependencies ?? {}), + ...Object.keys(remotePackageJson.optionalDependencies ?? {}), + ]); + const runtimeImports = collectRuntimePackageImports( + agentHostEntryPoints.map(entryPoint => path.join(repositoryRoot, entryPoint)) + ); + const missingDependencies = [...runtimeImports] + .filter(([packageName]) => !packagedDependencies.has(packageName)) + .map(([packageName, importers]) => `${packageName}: ${[...importers].sort().map(importer => path.relative(repositoryRoot, importer)).join(', ')}`) + .sort(); + + assert.deepStrictEqual(missingDependencies, []); + }); +}); + +function collectRuntimePackageImports(entryPoints: readonly string[]): Map> { + const pendingFiles = [...entryPoints]; + const visitedFiles = new Set(); + const packageImports = new Map>(); + const builtInModules = new Set([...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)]); + + while (pendingFiles.length > 0) { + const file = pendingFiles.pop()!; + if (visitedFiles.has(file)) { + continue; + } + visitedFiles.add(file); + + const sourceFile = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + for (const moduleSpecifier of getRuntimeModuleSpecifiers(sourceFile)) { + if (moduleSpecifier.startsWith('.')) { + const importedFile = resolveSourceImport(file, moduleSpecifier); + if (importedFile) { + pendingFiles.push(importedFile); + } + continue; + } + + if (builtInModules.has(moduleSpecifier)) { + continue; + } + + const packageName = getPackageName(moduleSpecifier); + let importers = packageImports.get(packageName); + if (!importers) { + importers = new Set(); + packageImports.set(packageName, importers); + } + importers.add(file); + } + } + + return packageImports; +} + +function getRuntimeModuleSpecifiers(sourceFile: ts.SourceFile): string[] { + const moduleSpecifiers: string[] = []; + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && isRuntimeImport(statement)) { + moduleSpecifiers.push(statement.moduleSpecifier.text); + } else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { + moduleSpecifiers.push(statement.moduleSpecifier.text); + } + } + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && (node.expression.text === 'require' || node.expression.text === 'nativeRequire') + && node.arguments.length === 1 + && ts.isStringLiteral(node.arguments[0]) + ) { + moduleSpecifiers.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + return moduleSpecifiers; +} + +function isRuntimeImport(statement: ts.ImportDeclaration): boolean { + const clause = statement.importClause; + if (!clause) { + return true; + } + if (clause.isTypeOnly) { + return false; + } + if (clause.name || !clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) { + return true; + } + return clause.namedBindings.elements.some(element => !element.isTypeOnly); +} + +function resolveSourceImport(importer: string, moduleSpecifier: string): string | undefined { + const unresolvedPath = path.resolve(path.dirname(importer), moduleSpecifier); + const candidates = [ + unresolvedPath, + unresolvedPath.replace(/\.js$/, '.ts'), + unresolvedPath.replace(/\.js$/, '.tsx'), + path.join(unresolvedPath, 'index.ts'), + ]; + return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); +} + +function getPackageName(moduleSpecifier: string): string { + const segments = moduleSpecifier.split('/'); + return moduleSpecifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} diff --git a/remote/package-lock.json b/remote/package-lock.json index 47a839233b83f5..02db175e51e9da 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -16,6 +16,7 @@ "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", + "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.44.0", @@ -644,6 +645,25 @@ "uuid": "^14.0.0" } }, + "node_modules/@vscode/fs-copyfile": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vscode/fs-copyfile/-/fs-copyfile-2.0.0.tgz", + "integrity": "sha512-ARb4+9rN905WjJtQ2mSBG/q4pjJkSRun/MkfCeRkk7h/5J8w4vd18NCePFJ/ZucIwXx/7mr9T6nz9Vtt1tk7hg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">=22.6.0" + } + }, + "node_modules/@vscode/fs-copyfile/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", diff --git a/remote/package.json b/remote/package.json index c65575a4dacd33..aed3d8405db9c8 100644 --- a/remote/package.json +++ b/remote/package.json @@ -11,6 +11,7 @@ "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", + "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/proxy-agent": "^0.44.0", @@ -65,6 +66,7 @@ "node-pty@1.2.0-beta.13": true, "@parcel/watcher@2.5.6": true, "@vscode/deviceid@0.1.5": true, + "@vscode/fs-copyfile@2.0.0": true, "@vscode/native-watchdog@1.4.6": true, "@vscode/spdlog@0.15.8": true, "@vscode/windows-registry@1.2.0": true, diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index d0ba71d2c355a5..27b7304b7c1b1e 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -108,12 +108,16 @@ export const EditSources = { mode: string | undefined; extensionId: VersionedExtensionId | undefined; codeBlockSuggestionId: EditSuggestionId | undefined; + harness?: string; + origin?: string; }) { return createEditSource({ source: 'Chat.applyEdits', $modelId: avoidPathRedaction(data.modelId), $extensionId: data.extensionId?.extensionId, $extensionVersion: data.extensionId?.version, + $harness: data.harness, + $origin: data.origin, $$languageId: data.languageId, $$sessionId: data.sessionId, $$requestId: data.requestId, diff --git a/src/vs/platform/agentHost/common/pendingRequestRegistry.ts b/src/vs/platform/agentHost/common/pendingRequestRegistry.ts index e3bd5d4649b99a..d77d8400de6d87 100644 --- a/src/vs/platform/agentHost/common/pendingRequestRegistry.ts +++ b/src/vs/platform/agentHost/common/pendingRequestRegistry.ts @@ -91,6 +91,11 @@ export class PendingRequestRegistry { } } + /** Whether a result arrived before a request registered under `key`. */ + hasBufferedResult(key: string): boolean { + return this._earlyResults.has(key); + } + /** * Resolve every parked deferred with `denyValue` and clear the registry. * diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 54a23b3b9818bc..d16c4b54d80a63 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1010,6 +1010,14 @@ export class AgentSideEffects extends Disposable { const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + if (toolCall + && toolCall.status !== ToolCallStatus.Streaming + && toolCall.status !== ToolCallStatus.Running + && toolCall.status !== ToolCallStatus.PendingConfirmation) { + this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); + this._logService.trace(`[AgentSideEffects] Dropping stale tool ready for ${e.state.toolCallId}: status=${toolCall.status}`); + return; + } const contributor = e.state.contributor ?? toolCall?.contributor; let effective = e; const clientShouldAutoApprove = autoApproval !== undefined diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 889eb4d6e0376e..bb184eb4b32e89 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1923,6 +1923,15 @@ export class CopilotAgentSession extends Disposable { const isShellRequest = request.kind === 'shell' || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); + if (request.kind === 'custom-tool' + && typeof request.toolName === 'string' + && this._clientToolNames.has(request.toolName) + && this._pendingClientToolCalls.hasBufferedResult(toolCallId) + ) { + this._logService.info(`[Copilot:${this.sessionId}] Auto-approving client tool ${request.toolName} because its result arrived before the permission request`); + return { kind: 'approve-once' }; + } + this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); const deferred = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts b/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts index dac9dbaadf5066..ac169ab78602bd 100644 --- a/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts +++ b/src/vs/platform/agentHost/test/common/pendingRequestRegistry.test.ts @@ -102,6 +102,15 @@ suite('PendingRequestRegistry', () => { assert.strictEqual(await promise, 'value'); }); + test('hasBufferedResult reports only unconsumed early results', async () => { + const registry = new PendingRequestRegistry(); + assert.strictEqual(registry.hasBufferedResult('k'), false); + registry.respondOrBuffer('k', 'early'); + assert.strictEqual(registry.hasBufferedResult('k'), true); + await registry.register('k'); + assert.strictEqual(registry.hasBufferedResult('k'), false); + }); + test('respondOrBuffer: a buffered `undefined` value still resolves a subsequent register', async () => { // Guards against the `get() !== undefined` sentinel bug: when T includes // undefined, a buffered undefined must be distinguished from "no entry" diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 2ee49fa7b2b69e..33e76c5afd9779 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -2351,6 +2351,65 @@ suite('AgentSideEffects', () => { 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); + test('tool_ready is dropped when the tool completes while permission lookup is pending', async () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'disconnected-client' }, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References', + invocationMessage: 'Get VS Code API References', toolInput: '{"query":"test"}', + confirmationTitle: 'Allow tool call?', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, + }); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-stale-ready', + invocationMessage: 'Get VS Code API References', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'tc-stale-ready', + result: { + success: false, + pastTenseMessage: 'Get VS Code API References failed', + error: { message: 'Client disconnected' }, + }, + }); + + await Promise.resolve(); + + const toolCall = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts + .find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-stale-ready'); + assert.deepStrictEqual({ + status: toolCall?.kind === ResponsePartKind.ToolCall ? toolCall.toolCall.status : undefined, + readyActions: envelopes.filter(e => e.action.type === ActionType.ChatToolCallReady).length, + }, { + status: ToolCallStatus.Completed, + readyActions: 1, + }); + }); + test('tool_ready for an additional chat is emitted on that chat channel', async () => { setupSession(); const chatUri = buildChatUri(sessionUri.toString(), 'peer'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index f3c7fe6fd00dcd..1d1ec2feaf251d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -4463,7 +4463,10 @@ suite('CopilotAgentSession', () => { }); test('permission request before client tool handler emits only confirmation ready', async () => { - const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + clientSnapshot: snapshot, + activeClientToolSet: activeClientToolSetWith('test-client'), + }); mockSession.fire('tool.execution_start', { toolCallId: 'tc-ready-data', @@ -4612,6 +4615,53 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.resultType, 'success'); assert.strictEqual(result.textResultForLlm, 'buffered result'); }); + + test('completion arriving before the permission request unblocks the SDK', async () => { + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('client-disconnected', snapshot.tools); + const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-complete-before-permission', + toolName: 'my_tool', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + + session.handleClientToolCallComplete('tc-complete-before-permission', { + success: false, + pastTenseMessage: 'my_tool failed', + error: { message: 'Client disconnected' }, + }); + + const permissionPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-complete-before-permission', + toolName: 'my_tool', + }); + let permissionResult: Awaited | undefined; + void permissionPromise.then(result => permissionResult = result); + await Promise.resolve(); + if (!permissionResult) { + session.respondToPermissionRequest('tc-complete-before-permission', false); + await permissionPromise; + } + + const toolResult = await invokeClientToolHandler(runtime.createClientSdkTools()[0], 'tc-complete-before-permission'); + assert.deepStrictEqual({ + permissionResult, + pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, + toolResult, + }, { + permissionResult: { kind: 'approve-once' }, + pendingConfirmations: 0, + toolResult: { + textResultForLlm: 'Client disconnected', + resultType: 'failure', + error: 'Client disconnected', + binaryResultsForLlm: undefined, + }, + }); + }); }); // ---- Server tools ------------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml b/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml new file mode 100644 index 00000000000000..5b110e6eaf2662 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/captures/agentHostE2E/copilotcli-client-tool-disconnect-before-permission-still-completes-the-turn.yaml @@ -0,0 +1,36 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Call the get_magic_word tool and then report whether it succeeded. + response: + content: + - type: tool_use + id: toolcall_0 + name: get_magic_word + input: {} + stopReason: tool_use + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Call the get_magic_word tool and then report whether it succeeded. + - role: assistant + content: + - type: thinking + - type: tool_use + name: get_magic_word + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Client copilot-client-tool-disconnect disconnected before completing get_magic_word + response: + content: The tool call **failed**. The client disconnected before the `get_magic_word` tool could complete. This appears to be a connectivity issue between the client and the runtime service. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts index b405d25f8e8987..6a24df593dab8b 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts @@ -90,6 +90,64 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { await runAhpSnapshotTest(client, COPILOT_CONFIG, this.test!, createdSessions, tempDirs); }); + test('client tool disconnect before permission still completes the turn', async function () { + this.timeout(180_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-client-tool-disconnect-')); + tempDirs.push(workingDirectory); + const clientId = 'copilot-client-tool-disconnect'; + const sessionUri = await createRealSession(client, COPILOT_CONFIG, clientId, createdSessions, URI.file(workingDirectory)); + + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId, + displayName: 'Test Client', + tools: [{ + name: 'get_magic_word', + description: 'Returns the secret magic word. Call this when asked for the magic word.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }], + }, + }, + }); + dispatchTurn(client, sessionUri, 'turn-client-tool-disconnect', 'Call the get_magic_word tool and then report whether it succeeded.', 2); + + const toolStart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallStart')) { + return false; + } + const action = getActionEnvelope(n).action as { toolName: string }; + return action.toolName === 'get_magic_word'; + }, 90_000); + const toolCallId = (getActionEnvelope(toolStart).action as { toolCallId: string }).toolCallId; + + client.notify('unsubscribe', { channel: sessionUri }); + + const failedCompletion = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallComplete')) { + return false; + } + const action = getActionEnvelope(n).action as { toolCallId: string; result: { success: boolean } }; + return action.toolCallId === toolCallId && !action.result.success; + }, 30_000); + const failedCompletionSeq = getActionEnvelope(failedCompletion).serverSeq; + + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + + const staleReady = client.receivedNotifications(n => { + if (!isActionNotification(n, 'chat/toolCallReady')) { + return false; + } + const envelope = getActionEnvelope(n); + const action = envelope.action as { toolCallId: string }; + return envelope.serverSeq > failedCompletionSeq && action.toolCallId === toolCallId; + }); + assert.deepStrictEqual(staleReady, []); + }); + suiteTeardown(async function () { this.timeout(60_000); await lease.dispose(); diff --git a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts index 070b0447126973..ff65598dcb2720 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts @@ -132,7 +132,7 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi } if (visible.length === 0 || !showTranscript) { - const handsFree = configurationService.getValue('agents.voice.handsFree') !== false; + const handsFree = configurationService.getValue('agents.voice.handsFree') === true; if (!showTranscript && voiceState === 'listening') { // Transcript is disabled: surface a minimal "Listening..." overlay // while listening so the user has feedback. Cleared in any other state. diff --git a/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css index 4977a823fe0bde..0963ad815058d3 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css +++ b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css @@ -22,9 +22,6 @@ --prompt-timeline-rail-width: 36px; /* Minimum clear space guaranteed between the message content and the rail marks. */ --prompt-timeline-content-gap: 24px; - /* Gutter reserved for the transcript's native scrollbar (its ~10px width + a small gap) so the - * marks sit to its left and it stays grabbable. */ - --prompt-timeline-scrollbar-gutter: 16px; } /* Reserve room on the transcript's right edge so message content clears the rail's marks. @@ -181,11 +178,81 @@ font-variant-numeric: tabular-nums; } -/* Overview-ruler style: the session compressed into the rail height like the editor overview ruler. Marks sit in a gutter to the LEFT of the transcript's native scrollbar (via the right inset) so the pills never overlap the slider and the scrollbar stays grabbable. The gutter is set from the real scrollbar width by the contribution; the fallback covers the default 10px slider + a small gap. */ +/* Overview-ruler style: the session compressed into the rail height like the editor overview ruler. + * The marks column IS the scrollbar lane: it sits at the transcript's right edge over the (hidden) + * native slider, and the rail draws its own thumb below the marks. At rest the lane is a plain + * scrollbar (just the thumb); on engagement the thumb recedes and the marks fan in over the same + * column, so the scrollbar visually morphs into the fan. */ .prompt-timeline-ruler-marks { position: absolute; - inset: 0 var(--prompt-timeline-scrollbar-gutter, 16px) 0 0; + inset: 0 0 0 auto; + width: 22px; + /* The whole column is the hover/scrub surface: hovering blooms the fisheye "fan" and lets it + * follow the cursor; dragging scrubs the transcript (see the rail's pointer handlers). */ + pointer-events: auto; + /* The marks stay quiet (invisible) at rest and during gentle scrolling so the transcript reads + * calm — only a deliberate gesture surfaces them: hovering the lane (`:hover`), the fisheye bloom + * on a hard scroll (`.engaged`), a scrub drag (`.scrubbing`), or keyboard focus inside the rail + * (`:focus-within`). */ + opacity: 0; + transition: opacity 200ms ease; + z-index: 2; +} + +/* Reveal the marks whenever the timeline is engaged: while scrolling + its linger (`.engaged`, which + * also covers reduced-motion where the fan is disabled), while hovering the lane, while scrubbing, or + * whenever keyboard focus is inside the rail (so tabbing to a mark and "Go to Prompt" always show it). */ +.prompt-timeline-rail.engaged .prompt-timeline-ruler-marks, +.prompt-timeline-rail:hover .prompt-timeline-ruler-marks, +.prompt-timeline-rail.scrubbing .prompt-timeline-ruler-marks, +.prompt-timeline-rail:focus-within .prompt-timeline-ruler-marks { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-marks { + transition: none; + } +} + +/* The rail's own scrollbar thumb (the transcript's native slider is hidden while the rail is active). + * A plain, translucent scrollbar at rest; it fades out as the fan blooms — or stays put while you + * drag it to scrub — so the lane reads as one surface morphing scrollbar <-> fan. Non-interactive: + * the marks column above owns hover + scrub. */ +.prompt-timeline-ruler-thumb { + position: absolute; + right: 4px; + width: 9px; + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-scrollbarSlider-background); pointer-events: none; + transition: opacity 160ms ease; + z-index: 1; +} + +.prompt-timeline-ruler-thumb.hidden { + display: none; +} + +/* The thumb recedes whenever the marks are revealed (engaged/hover/keyboard focus), but stays visible + * while scrubbing so dragging the lane feels like grabbing a scrollbar. */ +.prompt-timeline-rail.engaged:not(.scrubbing) .prompt-timeline-ruler-thumb, +.prompt-timeline-rail:hover:not(.scrubbing) .prompt-timeline-ruler-thumb, +.prompt-timeline-rail:focus-within:not(.scrubbing) .prompt-timeline-ruler-thumb { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-thumb { + transition: none; + } +} + +/* The rail is the scrollbar while it is active, so hide the transcript's own vertical slider for a + * single lane. Scoped to the top-level transcript list via a direct-child chain (nested scrollables + * inside rows keep their sliders) and only while the rail is actually showing (`.prompt-timeline-active`). */ +.prompt-timeline-host.prompt-timeline-active .interactive-list > .monaco-list > .monaco-scrollable-element > .scrollbar.vertical { + display: none; } /* Each mark is a >=24px hit target positioned at its proportional scroll offset. */ @@ -227,7 +294,26 @@ height: 3px; border-radius: 2px; background-color: var(--vscode-scrollbarSlider-background); - transition: width 80ms ease, height 80ms ease, background-color 80ms ease; + /* `transform` is animated for the fisheye "fan" magnification; keep origin at the transcript + * edge so the bar grows leftward, never across the scrollbar. */ + transform-origin: right center; + transition: transform 90ms ease, width 80ms ease, height 80ms ease, background-color 80ms ease; +} + +/* The mark button carries the fan's neighbour-spread (translateY); transition it so bloom and + * collapse both ease. `top` is only transitioned under `.glide` (drift), so this stays independent. */ +.prompt-timeline-ruler-mark { + transition: transform 90ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-bar { + transition: width 80ms ease, height 80ms ease, background-color 80ms ease; + } + + .prompt-timeline-ruler-mark { + transition: none; + } } /* Two-tone edited signal: a green added segment and a red removed segment, sized by the turn's split. */ diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts index 9ee7f466c19f27..69172bd3155ae9 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts @@ -47,6 +47,12 @@ export interface IPromptScrollLayout { readonly marks: readonly { readonly requestId: string; readonly top: number }[]; /** Total content height in the estimated space, matching `marks`. */ readonly total: number; + /** Current scroll offset (px, the transcript's real scroll space) — drives the rail's own scrollbar thumb. */ + readonly scrollTop: number; + /** Full scrollable content height (px, the transcript's real scroll space). */ + readonly scrollHeight: number; + /** Visible viewport height (px) of the transcript list — the scrollbar's `visibleSize`. */ + readonly viewportHeight: number; } /** A single tick shown on the prompt timeline rail. */ @@ -203,9 +209,10 @@ export class PromptTimelineModel extends Disposable { /** * The prompts' positions for the overview-ruler rail, in an *estimated* - * content space that stays stable while the transcript virtualizes. There is no - * viewport thumb: the native scrollbar is the drag affordance and the active - * mark is the "you-are-here", so the rail never draws a second slider. + * content space that stays stable while the transcript virtualizes. The rail + * draws its own scrollbar thumb from `scrollTop`/`scrollHeight` (the transcript's + * native scrollbar is hidden while the rail is active) so the whole lane is one + * surface: a plain scrollbar that blooms into the prompt fan on engagement. * * The chat list's own height model (`getElementTop`/`scrollHeight`) guesses * every un-rendered row at one flat default height (200px). Real turns are @@ -231,7 +238,7 @@ export class PromptTimelineModel extends Disposable { marks.push({ requestId: item.id, top: tops[i] }); } } - return { marks, total }; + return { marks, total, scrollTop: this.widget.scrollTop, scrollHeight: this.widget.scrollHeight, viewportHeight: this.widget.viewportHeight }; } /** diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts index 303f433c8c4c20..f8dcce564f0ba2 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts @@ -24,6 +24,8 @@ export interface IPromptTimelineRail extends IDisposable { /** Fired when a mark is chosen (click / keyboard), with its request id. */ readonly onDidSelect: Event; + /** Fired while dragging the rail lane to scrub, with the requested transcript scroll offset (px). */ + readonly onDidScrub: Event; /** Fired to review all of a prompt's changes. */ readonly onDidReview: Event; /** Fired to review a single changed file of a prompt. */ @@ -31,6 +33,9 @@ export interface IPromptTimelineRail extends IDisposable { setFilesProvider(provider: (tick: PromptTick) => readonly PromptFileDiff[]): void; setTicks(ticks: readonly PromptTick[]): void; + + /** Records a hard/fast wheel flick; the fan blooms only if a real transcript scroll follows shortly after. */ + notifyHardWheel(): void; setActive(requestId: string | undefined): void; focusTick(requestId: string): void; setHostWidth(width: number): void; diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts index b07d7d55db9ac1..e1f085ef044e50 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, append, clearNode, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableGenericMouseUpListener, addDisposableListener, append, clearNode, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -17,10 +18,29 @@ import './media/promptTimeline.css'; /** Minimum clickable target size (WCAG 2.5.8) for each mark's hit area. */ const MIN_TARGET = 24; -/** Below this transcript width the rail hides so it does not crowd the content. */ -const MIN_HOST_WIDTH = 320; +/** Below this transcript width the rail hides so it does not crowd the content (and the native scrollbar is kept). */ +export const MIN_HOST_WIDTH = 320; /** Skip re-positioning a mark for sub-pixel drift, so estimate noise doesn't cause micro-jitter. */ const RELAYOUT_MIN_DELTA = 0.5; +/** Fisheye "fan" lens: standard deviation (px) of the magnification falloff around the focus. */ +const FAN_SIGMA = 40; +/** Fisheye "fan" lens: how far (px) neighbouring marks are pushed apart around the focus. */ +const FAN_SPREAD = 14; +/** The fan lingers this long (ms) after the last scroll — while the pointer is away — before collapsing. */ +const FAN_LINGER = 2000; +/** A hard wheel flick only reveals the fan if the transcript actually scrolls within this window (ms) — so flicking against the top/bottom limit, which moves nothing, never blooms it. */ +const HARD_WHEEL_REVEAL_WINDOW = 200; +/** Minimum height (px) of the rail's own scrollbar thumb so it stays grabbable on tall transcripts. */ +const THUMB_MIN_HEIGHT = 24; +/** + * Pill placement. `'proportional'` scatters pills at their real scroll position (an overview ruler); + * `'even'` stacks them as an evenly-spaced dock centred in the lane (stable under virtualization, and + * tidier when big responses would otherwise cluster the pills). The scrollbar thumb stays proportional + * either way, and scrubbing hides the pills, so `'even'` does not make dragging feel disconnected. + */ +const PILL_LAYOUT: 'proportional' | 'even' = 'even'; +/** Even layout: vertical gap (px) between pill centres — also the min so hit targets never overlap. */ +const EVEN_PILL_SPACING = 26; interface IMarkEntry { tick: PromptTick; @@ -28,6 +48,8 @@ interface IMarkEntry { readonly bar: HTMLElement; /** Last applied `top` (px) so tiny relayout deltas can be skipped. */ lastTop?: number; + /** Proportional (pre-fan) centre (px) from the last layout, used as the fan's rest position. */ + baseCenter?: number; } /** @@ -41,6 +63,8 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli private readonly _domNode: HTMLElement; private readonly _marksContainer: HTMLElement; + /** The rail's own scrollbar thumb (the transcript's native slider is hidden while the rail is active). Shown at rest as a plain scrollbar; recedes as the fan blooms. */ + private readonly _thumb: HTMLElement; private readonly _card: PromptTimelineCard; private readonly _markDisposables = this._register(new DisposableStore()); private readonly _marks: IMarkEntry[] = []; @@ -55,10 +79,37 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli private _railHeight = 0; /** Coalesces scroll-driven relayouts to one per animation frame. */ private readonly _relayoutScheduled = this._register(new MutableDisposable()); + /** Lane-local Y the fisheye "fan" magnifies around, or undefined when the fan is at rest. */ + private _fanCenter: number | undefined; + /** Cached top (client px) of the marks column, captured on pointer-enter so the fan can follow the cursor without a per-move reflow. */ + private _laneTop = 0; + /** Cached client Y of the rail's top edge, refreshed on resize; used to place the hover card and derive the lane-local pointer Y without a per-hover forced reflow. */ + private _domTop: number | undefined; + /** True while the pointer is over the lane (keeps the fan open; the linger only collapses once it leaves). */ + private _hovering = false; + /** Timestamp (ms) of the last hard/fast wheel flick; the fan blooms only if a real scroll follows it within {@link HARD_WHEEL_REVEAL_WINDOW}. */ + private _hardWheelAt = 0; + /** Last scroll offset seen, to detect real transcript movement (vs. a wheel that hit the scroll limit and moved nothing). */ + private _lastScrollTop: number | undefined; + /** Collapses the fan {@link FAN_LINGER}ms after the last scroll, unless the pointer is keeping it open. */ + private readonly _fanHide = this._register(new MutableDisposable()); + /** Timestamp (ms) of the last scroll/leave that should keep the fan up; the linger timer re-checks this instead of being churned every scroll frame. */ + private _lastFanActivityAt = 0; + /** When the user prefers reduced motion the fan is disabled (marks stay their calm rest size). */ + private _reducedMotion = false; + /** True while the user is dragging the lane to scrub the transcript. */ + private _scrubbing = false; + /** True while keyboard focus is inside the rail: the marks stay revealed (`:focus-within`) but the fisheye is suppressed. */ + private _focused = false; + /** Window listeners for the in-progress scrub drag; cleared on mouse-up (and on rail dispose). */ + private readonly _scrubSession = this._register(new MutableDisposable()); private readonly _onDidSelect = this._register(new Emitter()); readonly onDidSelect: Event = this._onDidSelect.event; + private readonly _onDidScrub = this._register(new Emitter()); + readonly onDidScrub: Event = this._onDidScrub.event; + private readonly _onDidReview = this._register(new Emitter()); readonly onDidReview: Event = this._onDidReview.event; @@ -74,6 +125,10 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli this._domNode.setAttribute('role', 'toolbar'); this._domNode.setAttribute('aria-orientation', 'vertical'); this._marksContainer = append(this._domNode, $('.prompt-timeline-ruler-marks')); + // The rail's own scrollbar thumb. It sits under the marks and shows the viewport position as + // a plain scrollbar at rest; while the fan is bloomed it fades out so the lane reads as one + // surface morphing from scrollbar → fan (the transcript's native slider is hidden via CSS). + this._thumb = append(this._domNode, $('.prompt-timeline-ruler-thumb')); this._card = this._register(new PromptTimelineCard(this._domNode)); this._register(this._card.onDidReview(tick => this._onDidReview.fire(tick))); this._register(this._card.onDidReviewFile(e => this._onDidReviewFile.fire(e))); @@ -81,8 +136,56 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli // Toolbar keyboard model: one Tab stop, Arrow/Home/End move between marks. this._register(addDisposableListener(this._marksContainer, EventType.KEY_DOWN, e => this._onMarksKeyDown(e))); + // The marks column IS the scrollbar lane. Hovering anywhere along it blooms the fisheye "fan" + // and lets it FOLLOW the cursor (a macOS-dock feel); dragging it scrubs the transcript like a + // scrollbar. The lane's top edge is cached (refreshed on resize) so each hover/move converts + // the pointer Y to lane-local space without a per-event getBoundingClientRect (a forced reflow + // that would stutter while the transcript's styles are dirty during scroll). + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_ENTER, e => { + this._laneTop = this._laneTopNow(); + this._hovering = true; + this._fanHide.clear(); // hovering keeps the fan open — no linger countdown + if (!this._scrubbing) { + this._engage(e.clientY - this._laneTop); + } + })); + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_MOVE, e => { + this._hovering = true; + if (!this._scrubbing) { + this._engage(e.clientY - this._laneTop); + } + })); + this._register(addDisposableListener(this._marksContainer, EventType.MOUSE_LEAVE, () => { + this._hovering = false; + if (!this._scrubbing) { + this._scheduleFanHide(); + } + })); + // Drag the lane (anywhere that is not a mark) to scrub the transcript, like grabbing a + // scrollbar. Generic mouse-down so it also works with touch/pointer on iOS. + this._register(addDisposableGenericMouseDownListener(this._marksContainer, e => this._beginScrub(e))); + + // The fan is a pointer-only flourish, so it must respect reduced-motion. Read it now and + // track changes; keyboard users always get the calm, static marks + card + navigation. + const win = getWindow(this._domNode); + const reducedMotionQuery = win.matchMedia?.('(prefers-reduced-motion: reduce)'); + if (reducedMotionQuery) { + this._reducedMotion = reducedMotionQuery.matches; + this._register(addDisposableListener(reducedMotionQuery, 'change', () => { + this._reducedMotion = reducedMotionQuery.matches; + this._applyFan(); + })); + } + + // Keyboard focus reveals a calm dock: the marks stay up (`:focus-within` in CSS) but the fisheye + // is suppressed, so tabbing through never leaves the pills magnified from an earlier scroll. + this._register(addDisposableListener(this._domNode, EventType.FOCUS_IN, () => { + this._focused = true; + this._collapseFan(); + })); this._register(addDisposableListener(this._domNode, EventType.FOCUS_OUT, () => { if (!this._domNode.contains(getWindow(this._domNode).document.activeElement)) { + this._focused = false; this._card.scheduleHide(); } })); @@ -121,6 +224,9 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli this._renderMark(entry, tick); const requestId = tick.requestId; this._markDisposables.add(addDisposableListener(button, EventType.CLICK, () => this._onDidSelect.fire(requestId))); + // A press on a mark must not start a lane scrub — let the click jump to the prompt. Generic + // so it also intercepts the pointer-down used on iOS. + this._markDisposables.add(addDisposableGenericMouseDownListener(button, e => e.stopPropagation())); this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_ENTER, () => this._showCard(entry))); this._markDisposables.add(addDisposableListener(button, EventType.FOCUS, () => { this._showCard(entry); this._updateTabStops(this._marks.indexOf(entry)); })); this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_LEAVE, () => this._card.scheduleHide())); @@ -185,9 +291,81 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } } + /** + * Records a hard/fast wheel flick. The fan does NOT bloom here — it blooms only if the transcript + * actually scrolls shortly after (see {@link setScrollLayout}). This way a hard flick against the + * top/bottom scroll limit, which moves nothing, never reveals the fan. + */ + notifyHardWheel(): void { + this._hardWheelAt = Date.now(); + } + + /** Lane-local Y of the active mark (the prompt currently scrolled to), or the nearest visible one. */ + private _activeCenter(): number | undefined { + const active = this._marks.find(m => m.tick.requestId === this._activeRequestId && m.baseCenter !== undefined); + if (active?.baseCenter !== undefined) { + return active.baseCenter; + } + // Fall back to the last laid-out mark (or the first) so a scroll still blooms somewhere real. + const laidOut = this._marks.filter(m => m.baseCenter !== undefined); + return laidOut.at(-1)?.baseCenter; + } + + /** + * Lane-local Y for the fisheye focus while SCROLLING: glides continuously with the viewport by + * interpolating between pills. Each prompt has a content position (`layout.marks[].top`) and a dock + * position (`baseCenter`); we find where the viewport (`scrollTop`) sits between two prompts in + * content space and place the focus at the matching fraction between their dock positions. So the + * fisheye travels smoothly through the pills as you scroll (rather than snapping at prompt + * boundaries), while still tracking the real scroll position. Returns `undefined` if not laid out. + */ + private _scrollFanCenter(): number | undefined { + const layout = this._layout; + if (!layout) { + return undefined; + } + const topById = new Map(layout.marks.map(m => [m.requestId, m.top])); + const pts: { contentTop: number; center: number }[] = []; + for (const entry of this._marks) { + const contentTop = topById.get(entry.tick.requestId); + if (contentTop !== undefined && entry.baseCenter !== undefined) { + pts.push({ contentTop, center: entry.baseCenter }); + } + } + if (pts.length === 0) { + return undefined; + } + pts.sort((a, b) => a.contentTop - b.contentTop); + // `contentTop`s are in the adaptive ESTIMATED space (summing to `layout.total`), but + // `layout.scrollTop`/`scrollHeight` are the transcript's REAL scroll space. Under virtualization + // those spaces differ, so scale the scroll position into the estimated space before comparing. + const scrollTop = layout.scrollHeight > 0 + ? (layout.scrollTop / layout.scrollHeight) * layout.total + : layout.scrollTop; + if (scrollTop <= pts[0].contentTop) { + return pts[0].center; + } + const last = pts[pts.length - 1]; + if (scrollTop >= last.contentTop) { + return last.center; + } + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + if (scrollTop >= a.contentTop && scrollTop <= b.contentTop) { + const span = b.contentTop - a.contentTop; + const frac = span > 0 ? (scrollTop - a.contentTop) / span : 0; + return a.center + frac * (b.center - a.center); + } + } + return last.center; + } + setActive(requestId: string | undefined): void { this._activeRequestId = requestId; this._updateActiveClasses(); + // Note: the scroll-driven fan follow is handled continuously in `_relayout` (it glides with the + // viewport), so we do not re-centre here — that would snap the fan at prompt boundaries. } focusTick(requestId: string): void { @@ -202,11 +380,46 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } setScrollLayout(layout: IPromptScrollLayout | undefined): void { + const prevScrollTop = this._lastScrollTop; this._layout = layout; + if (layout) { + this._lastScrollTop = layout.scrollTop; + // Only react to a REAL scroll movement. A wheel flick against the top/bottom limit fires + // wheel events (so notifyHardWheel runs) but doesn't change scrollTop, so it never reveals + // the fan here. Programmatic nudges during virtualization re-measure lack a recent hard + // wheel, so they don't reveal it either. + if (prevScrollTop !== undefined && Math.abs(layout.scrollTop - prevScrollTop) > 0.5) { + this._onScrolled(); + } + } // Scroll fires many events per frame; coalesce so we lay out (and touch the DOM) once. this._scheduleRelayout(); } + /** + * Handles a real transcript scroll: blooms the fan if it followed a deliberate hard flick, and + * keeps it alive (re-arms the linger) while you keep scrolling. Pointer hover/scrub own the fan + * on their own, so this defers to them. + */ + private _onScrolled(): void { + if (this._hovering || this._scrubbing || this._focused) { + return; + } + if (this._domNode.classList.contains('engaged')) { + // Already open: keep it up while actively scrolling (the glide happens in `_relayout`). + this._scheduleFanHide(); + return; + } + // Not open yet: only a deliberate hard flick that actually moved the transcript blooms it. + if (Date.now() - this._hardWheelAt <= HARD_WHEEL_REVEAL_WINDOW) { + const center = this._scrollFanCenter() ?? this._activeCenter(); + if (center !== undefined) { + this._engage(center); + this._scheduleFanHide(); + } + } + } + /** Coalesces relayout to at most once per animation frame. */ private _scheduleRelayout(): void { if (this._relayoutScheduled.value) { @@ -225,6 +438,8 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli const layout = this._layout; const overflowing = this._hostWidth < MIN_HOST_WIDTH; this._domNode.classList.toggle('overflowing', overflowing); + // Position the rail's own scrollbar thumb (independent of the marks, which may be absent). + this._layoutThumb(); if (overflowing || height <= 0 || !layout || layout.total <= 0) { return; } @@ -238,19 +453,29 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli if (top === undefined) { entry.button.classList.add('hidden'); entry.lastTop = undefined; + entry.baseCenter = undefined; + entry.button.style.transform = ''; + entry.bar.style.transform = ''; continue; } entry.button.classList.remove('hidden'); visible.push({ entry, center: top * scale }); } - // Prompts can sit arbitrarily close in content space (a short turn, or after height - // re-estimates settle), which would let the >=24px hit targets overlap. Push adjacent - // marks apart to keep a full target's spacing while staying as close to their - // proportional position as the rail allows. - spaceMarkCenters(visible, height, MIN_TARGET); + if (PILL_LAYOUT === 'even') { + // Evenly-spaced dock, centred vertically as a group. Stable under virtualization (pills do + // not drift as row heights re-measure) and tidy when big responses would cluster them. + this._spaceEvenCenters(visible, height); + } else { + // Prompts can sit arbitrarily close in content space (a short turn, or after height + // re-estimates settle), which would let the >=24px hit targets overlap. Push adjacent + // marks apart to keep a full target's spacing while staying as close to their + // proportional position as the rail allows. + spaceMarkCenters(visible, height, MIN_TARGET); + } for (const { entry, center } of visible) { + entry.baseCenter = center; // The button is a >=24px hit target centered on the mark's (spaced) position. const y = center - MIN_TARGET / 2; // Skip sub-pixel drift so estimate noise doesn't jitter the marks. @@ -260,6 +485,200 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli entry.lastTop = y; entry.button.style.top = `${y}px`; } + + // While the fan is open because of scrolling (not steered by the pointer, and not while keyboard + // focus is showing the calm dock), glide its focus with the viewport so the fisheye travels + // smoothly through the pills as you scroll. + if (this._domNode.classList.contains('engaged') && !this._hovering && !this._scrubbing && !this._focused) { + const scrollCenter = this._scrollFanCenter(); + if (scrollCenter !== undefined) { + this._fanCenter = scrollCenter; + } + } + + // Re-apply the pointer fisheye against the freshly measured rest positions. + this._applyFan(); + } + + /** + * Even (dock) placement: stacks the pills at a fixed spacing and centres the whole group in the + * lane. If the group is taller than the lane it distributes across the full height instead, so a + * long session still fits. Mutates each item's `center` in place. + */ + private _spaceEvenCenters(visible: { entry: IMarkEntry; center: number }[], height: number): void { + const n = visible.length; + if (n === 0) { + return; + } + const groupHeight = n * EVEN_PILL_SPACING; + let start: number; + let step: number; + if (groupHeight <= height) { + // Compact group centred vertically. + step = EVEN_PILL_SPACING; + start = (height - groupHeight) / 2 + step / 2; + } else { + // Too many to fit at the ideal spacing: spread evenly across the full height. + step = (height - EVEN_PILL_SPACING) / (n - 1); + start = EVEN_PILL_SPACING / 2; + } + for (let i = 0; i < n; i++) { + visible[i].center = start + i * step; + } + } + + /** + * The rail's scrollbar geometry, mapping the transcript's real scroll space onto the rail track, + * exactly like the editor's {@link ScrollbarState}: the thumb size is the viewport's share of the + * content (floored to a grabbable minimum), and the thumb travels `track - thumbSize` as the scroll + * position travels `scrollHeight - viewportHeight`. Returns `undefined` when there is nothing to + * scroll or the rail is too narrow to act as the scrollbar. + */ + private _thumbMetrics(): { size: number; ratio: number; maxScroll: number } | undefined { + const track = this._railHeight; + const layout = this._layout; + if (!layout || track <= 0 || this._hostWidth < MIN_HOST_WIDTH) { + return undefined; + } + const viewport = layout.viewportHeight; + const scrollSize = layout.scrollHeight; + if (viewport <= 0 || scrollSize <= viewport + 1) { + return undefined; // content fits — no scrollbar needed + } + const size = Math.max(THUMB_MIN_HEIGHT, Math.floor(viewport / scrollSize * track)); + const maxScroll = scrollSize - viewport; + const ratio = (track - size) / maxScroll; + return { size, ratio, maxScroll }; + } + + /** Positions the rail's own scrollbar thumb from the transcript's real scroll offset, or hides it when there is nothing to scroll. */ + private _layoutThumb(): void { + const metrics = this._thumbMetrics(); + if (!metrics) { + this._thumb.classList.add('hidden'); + return; + } + const top = Math.max(0, Math.min(this._railHeight - metrics.size, this._layout!.scrollTop * metrics.ratio)); + this._thumb.classList.remove('hidden'); + this._thumb.style.top = `${top}px`; + this._thumb.style.height = `${metrics.size}px`; + } + + /** Begins a lane scrub (grab the scrollbar): scrolls to the pressed position and tracks the drag. */ + private _beginScrub(e: MouseEvent): void { + if (!this._thumbMetrics()) { + return; // nothing to scroll + } + e.preventDefault(); + this._scrubbing = true; + this._laneTop = this._laneTopNow(); + // Collapse the fan while scrubbing so the lane reads as a clean scrollbar being dragged. + this._collapseFan(); + this._domNode.classList.add('scrubbing'); + this._scrubTo(e.clientY); + const session = new DisposableStore(); + session.add(addDisposableGenericMouseMoveListener(getWindow(this._domNode), (ev: MouseEvent) => this._scrubTo(ev.clientY))); + session.add(addDisposableGenericMouseUpListener(getWindow(this._domNode), () => { + this._scrubbing = false; + this._domNode.classList.remove('scrubbing'); + this._scrubSession.clear(); + // Linger briefly after a scrub so the timeline stays up while the user reads where they landed. + if (!this._hovering) { + const center = this._activeCenter(); + if (center !== undefined) { + this._engage(center); + } + this._scheduleFanHide(); + } + })); + this._scrubSession.value = session; + } + + /** Maps a client Y on the lane to a transcript scroll offset and requests it (viewport centred on the cursor). */ + private _scrubTo(clientY: number): void { + const metrics = this._thumbMetrics(); + if (!metrics) { + return; + } + // Centre the thumb on the cursor, then invert the thumb->scroll ratio, like ScrollbarState. + const desiredThumbTop = (clientY - this._laneTop) - metrics.size / 2; + const scrollTop = metrics.ratio > 0 ? desiredThumbTop / metrics.ratio : 0; + this._onDidScrub.fire(Math.max(0, Math.min(metrics.maxScroll, scrollTop))); + } + + /** + * Fisheye "fan": magnify the marks near {@link _fanCenter} and gently spread their neighbours + * apart, so a dense cluster becomes easy to read and click. It is a pointer-only flourish layered + * on top of the proportional layout — the marks' `top` (owned by `_relayout`) is untouched; the + * fan only adds a CSS `transform`, so keyboard navigation and the base layout are unaffected. + * Disabled entirely under reduced-motion. + */ + private _applyFan(): void { + const center = this._fanCenter; + const fanning = center !== undefined && !this._reducedMotion; + for (const entry of this._marks) { + if (entry.baseCenter === undefined) { + continue; + } + if (!fanning) { + entry.button.style.transform = ''; + entry.bar.style.transform = ''; + continue; + } + const d = entry.baseCenter - center!; + const m = Math.exp(-(d * d) / (2 * FAN_SIGMA * FAN_SIGMA)); + // Spread neighbours away from the focus (dock feel) and grow the focused bar the most. + entry.button.style.transform = `translateY(${FAN_SPREAD * Math.tanh(d / FAN_SIGMA)}px)`; + entry.bar.style.transform = `scale(${1 + m * 0.9}, ${1 + m * 0.6})`; + } + } + + /** + * Opens the fan at {@link center} (lane-local Y): reveals the marks (via `.engaged`) and applies + * the fisheye. Reveal happens even under reduced motion (the marks just don't magnify). + */ + private _engage(center: number): void { + this._domNode.classList.add('engaged'); + this._fanCenter = center; + this._applyFan(); + } + + /** Collapses the fan back to the plain scrollbar (marks hidden, no fisheye). */ + private _collapseFan(): void { + if (!this._domNode.classList.contains('engaged')) { + return; + } + this._domNode.classList.remove('engaged'); + this._fanCenter = undefined; + this._applyFan(); + } + + /** + * (Re)starts the linger countdown: {@link FAN_LINGER}ms after the last scroll the fan collapses — + * but only if the pointer is not keeping it open. Called on every scroll frame and when the pointer + * leaves, so it avoids churning the timer: it just stamps the activity time and, when the single + * running timer fires, it re-arms for the remaining time if more scrolling happened since. + */ + private _scheduleFanHide(): void { + this._lastFanActivityAt = Date.now(); + if (!this._fanHide.value) { + this._armFanHide(FAN_LINGER); + } + } + + private _armFanHide(delay: number): void { + this._fanHide.value = disposableTimeout(() => { + this._fanHide.clear(); + if (this._hovering) { + return; // hovering keeps it up; leaving re-arms the countdown + } + const remaining = FAN_LINGER - (Date.now() - this._lastFanActivityAt); + if (remaining > 0) { + this._armFanHide(remaining); // more scrolling happened since — keep waiting + } else { + this._collapseFan(); + } + }, delay); } private _updateActiveClasses(): void { @@ -274,10 +693,25 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } } + /** Lane-local Y (client px) of the marks column top, from the cached rail top (refreshed on resize). Reads layout lazily only if the cache is not yet primed, so hovering never forces a reflow mid-scroll. */ + private _laneTopNow(): number { + if (this._domTop === undefined) { + this._domTop = this._domNode.getBoundingClientRect().top; + } + // The marks column is inset:0 at the top, so its client top equals the rail's. + return this._domTop; + } + private _showCard(entry: IMarkEntry): void { - const markRect = entry.button.getBoundingClientRect(); - const domRect = this._domNode.getBoundingClientRect(); - this._card.show(entry.tick, markRect.top - domRect.top + markRect.height / 2); + // Position the card from the mark's known lane-local centre instead of reading + // getBoundingClientRect (a forced synchronous layout that stutters while the transcript's + // styles are dirty during scroll). The marks column is inset:0 at the top, so `baseCenter` + // is the Y relative to the rail; the hovered mark sits near the fan focus, where its + // magnification translate is ~0, so this matches the visible position. Falls back to a + // measured rect only if the mark has not been laid out yet. + const centerY = entry.baseCenter + ?? (entry.button.getBoundingClientRect().top - this._domNode.getBoundingClientRect().top + MIN_TARGET / 2); + this._card.show(entry.tick, centerY); } private _ensureResizeObserver(): void { @@ -290,8 +724,10 @@ export class PromptTimelineRulerRail extends Disposable implements IPromptTimeli } this._resizeObserverReady = true; const observer = new ResizeObserverCtor(() => { - // Height only changes here (window/input-part resize); refresh the cache and lay out. + // Height only changes here (window/input-part resize); refresh the cached height and top + // (used by hover/scrub to avoid per-event reflows) and lay out. this._railHeight = this._domNode.clientHeight; + this._domTop = this._domNode.getBoundingClientRect().top; this._relayout(); }); observer.observe(this._domNode); diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts index 77cba5b5cf97b2..935ff0eecdff79 100644 --- a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getWindow } from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { addDisposableListener, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { IMouseWheelEvent, StandardWheelEvent } from '../../../../base/browser/mouseEvent.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -14,7 +15,12 @@ import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/con import { MIN_PROMPTS, PROMPT_TIMELINE_CONTRIB_ID, PROMPT_TIMELINE_ENABLED_SETTING } from '../common/promptTimeline.js'; import { PromptTimelineModel, PromptEntry } from './promptTimelineModel.js'; import { IPromptTimelineRail } from './promptTimelineRail.js'; -import { PromptTimelineRulerRail } from './promptTimelineRulerRail.js'; +import { MIN_HOST_WIDTH, PromptTimelineRulerRail } from './promptTimelineRulerRail.js'; + +/** Normalized wheel distance (device-independent units, ~1 per notch) accumulated within {@link WHEEL_WINDOW_MS} to count as a hard/fast scroll. */ +const HARD_WHEEL_DISTANCE = 20; +/** Rolling window for the wheel-velocity accumulator; a pause longer than this resets it. */ +const WHEEL_WINDOW_MS = 120; /** * Per-widget contribution that overlays a prompt timeline rail on the chat @@ -33,6 +39,8 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg /** Holds the model, rail and all their wiring while the feature is enabled. */ private readonly _enablement = this._register(new DisposableStore()); private _enabled = false; + /** Latest tick count is at or above {@link MIN_PROMPTS}; combined with the host width to decide whether the rail replaces the native scrollbar. */ + private _hasEnoughPrompts = false; constructor( private readonly widget: IChatWidget, @@ -64,6 +72,7 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg this._enablement.clear(); this._model = undefined; this._rail = undefined; + this._hasEnoughPrompts = false; if (enabled) { this._createRail(); } @@ -80,14 +89,22 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg rail.setFilesProvider(tick => model.getRequestFiles(tick)); this._enablement.add(rail.onDidSelect(requestId => model.reveal(requestId))); + // Dragging the rail lane scrubs the transcript (the rail is the scrollbar now, so it drives scroll). + this._enablement.add(rail.onDidScrub(scrollTop => { (this.widget as ChatWidget).scrollTop = scrollTop; })); this._enablement.add(rail.onDidReview(tick => { void model.reviewChanges(tick); })); this._enablement.add(rail.onDidReviewFile(e => { void model.reviewChanges(e.tick, e.file); })); + // A deliberate hard/fast scroll reveals the fan; capture phase so it is seen before the + // transcript's ScrollableElement consumes the wheel mid-content (see `_registerHardWheelDetector`). + this._enablement.add(this._registerHardWheelDetector(rail)); + this._enablement.add(autorun(reader => { const ticks = model.ticks.read(reader); // Toggle visibility before rendering so the rail's fit measurement in // setTicks runs against the displayed (non-zero height) element. - rail.domNode.classList.toggle('hidden', ticks.length < MIN_PROMPTS); + this._hasEnoughPrompts = ticks.length >= MIN_PROMPTS; + rail.domNode.classList.toggle('hidden', !this._hasEnoughPrompts); + this._updateNativeScrollbarHidden(); rail.setTicks(ticks); })); @@ -108,7 +125,7 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg // Anchor the absolutely-positioned overlay to the chat widget via a class // we own, removed on teardown so we never leave the foreign container mutated. host.classList.add('prompt-timeline-host'); - this._enablement.add(toDisposable(() => host.classList.remove('prompt-timeline-host'))); + this._enablement.add(toDisposable(() => host.classList.remove('prompt-timeline-host', 'prompt-timeline-active'))); host.appendChild(railNode); this._enablement.add(toDisposable(() => railNode.remove())); @@ -118,14 +135,49 @@ export class PromptTimelineWidgetContrib extends Disposable implements IChatWidg railNode.style.setProperty('--prompt-timeline-bottom', `${inputPart.height.read(reader)}px`); })); - // Report the host width so the rail can hide on very narrow transcripts. + // Report the host width so the rail can hide on very narrow transcripts, and keep the native + // scrollbar whenever the rail is too narrow to replace it. const ResizeObserverCtor = getWindow(host).ResizeObserver; if (ResizeObserverCtor) { - const observer = new ResizeObserverCtor(() => rail.setHostWidth(host.clientWidth)); + const observer = new ResizeObserverCtor(() => { + rail.setHostWidth(host.clientWidth); + this._updateNativeScrollbarHidden(); + }); observer.observe(host); this._enablement.add(toDisposable(() => observer.disconnect())); } rail.setHostWidth(host.clientWidth); + this._updateNativeScrollbarHidden(); + } + + /** + * Detects a deliberate hard/fast scroll from wheel velocity and tells the rail (it only blooms if a + * real scroll movement follows, so flicking against a scroll limit never opens it). Deltas are + * normalized via {@link StandardWheelEvent} so line-mode devices are not stuck below the threshold, + * and the listener is on the capture phase so it is seen before the transcript's ScrollableElement + * consumes the wheel mid-content. + */ + private _registerHardWheelDetector(rail: IPromptTimelineRail): IDisposable { + let wheelAcc = 0; + let wheelWindowStart = 0; + return addDisposableListener(this.widget.domNode, EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + const now = Date.now(); + if (now - wheelWindowStart > WHEEL_WINDOW_MS) { + wheelAcc = 0; + wheelWindowStart = now; + } + wheelAcc += Math.abs(new StandardWheelEvent(e).deltaY); + if (wheelAcc >= HARD_WHEEL_DISTANCE) { + wheelAcc = 0; + rail.notifyHardWheel(); + } + }, { capture: true, passive: true }); + } + + /** Hide the transcript's native scrollbar only while the rail is actually acting as it: enough prompts AND wide enough (below {@link MIN_HOST_WIDTH} the rail hides, so the native slider must stay). */ + private _updateNativeScrollbarHidden(): void { + const active = this._hasEnoughPrompts && this.widget.domNode.clientWidth >= MIN_HOST_WIDTH; + this.widget.domNode.classList.toggle('prompt-timeline-active', active); } // -- Navigation API (used by promptTimelineActions) -- diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 96b146c3766fd3..c91f76d88af7fe 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -503,7 +503,7 @@ configurationRegistry.registerConfiguration({ 'agents.voice.handsFree': { type: 'boolean', markdownDescription: nls.localize('agents.voice.handsFree', "When enabled, voice mode automatically re-enters listening after the assistant finishes speaking, so you can hold a hands-free back-and-forth conversation. When disabled, you start each turn manually. This controls only the auto-listen loop; how a turn ends is controlled by {0} and {1}.", '`#agents.voice.turn.silenceMs#`', '`#agents.voice.turn.stopPhrases#`'), - default: true, + default: false, scope: ConfigurationScope.APPLICATION, }, 'agents.voice.turn.silenceMs': { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index ae88bff0a3a29e..0e54770fd60f34 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -148,7 +148,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), - hideDisconnect: this.configurationService.getValue('agents.voice.handsFree') !== false, + hideDisconnect: this.configurationService.getValue('agents.voice.handsFree') === true, connect: () => { // Connecting from any surface marks onboarding as completed so // the main panel drops it too. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index 3bed9355e2573b..e635de18907943 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -279,7 +279,7 @@ export async function resolveMcpServerAuthentication( const scopes = options.scopes; for (const authorizationServer of protectedResource.authorization_servers ?? []) { const authorizationServerUri = URI.parse(authorizationServer); - const providerId = await getOrCreateProviderForMcpResource(authorizationServerUri, protectedResource, authenticationService, logService, options.logPrefix); + const providerId = await getOrCreateProviderForMcpResource(authorizationServerUri, protectedResource, authenticationService, logService, options.logPrefix, options.allowInteraction); if (!providerId) { continue; } @@ -317,10 +317,11 @@ async function getOrCreateProviderForMcpResource( authenticationService: IAuthenticationService, logService: ILogService, logPrefix: string, + allowCreation: boolean, ): Promise { const resourceUri = URI.parse(protectedResource.resource); const existing = await authenticationService.getOrActivateProviderIdForServer(authorizationServer, resourceUri); - if (existing) { + if (existing || !allowCreation) { return existing; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts index c4f4119beae7c2..0e288158e643c7 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts @@ -72,10 +72,6 @@ export class ClientToolSetsContribution extends Disposable implements IWorkbench 'testFailure', 'rename', 'usages', - 'extensions', - 'installExtension', - 'newWorkspace', - 'runCommand', 'toolSearch', ], })); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 7a25bbb6f33559..58379db3bd2ad5 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -1710,10 +1710,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } private _isHandsFreeEnabled(): boolean { - // Default-on: treat only an explicit `false` as disabled so an - // unresolved/undefined value still enables hands-free (matches the - // `handsFree` default and the window-service `!== false` check). - return this.configurationService.getValue('agents.voice.handsFree') !== false; + // Default-off: hands-free auto-listen is opt-in, so only an explicit + // `true` enables it. An unresolved/undefined value resolves to the + // `handsFree` default (`false`) and stays disabled. + return this.configurationService.getValue('agents.voice.handsFree') === true; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 14afc43baf598a..c55ee127bc6419 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -741,6 +741,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.listWidget.scrollHeight; } + get viewportHeight(): number { + return this.listWidget.renderHeight; + } + get attachmentModel(): ChatAttachmentModel { return this.input.attachmentModel; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 761ffcfc65393f..990a77cdae2ca8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -581,7 +581,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { - const handsFree = this.configurationService.getValue('agents.voice.handsFree') !== false; + const handsFree = this.configurationService.getValue('agents.voice.handsFree') === true; if (!showTranscript && voiceState === 'listening') { // Transcript is disabled: surface a minimal "Listening..." overlay // while listening so the user has feedback. Cleared in any other state. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index 6006cb00080ea7..270cc06150c419 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -237,6 +237,41 @@ suite('resolveMcpServerAuthentication', () => { requestedScopes: [['notifications']], }); }); + + test('does not attempt dynamic provider creation without user interaction', async () => { + const warnings: string[] = []; + const logService = new class extends NullLogService { + override warn(message: string): void { + warnings.push(message); + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IAuthenticationService, createMockAuthService({})); + instantiationService.stub(IAuthenticationMcpAccessService, {}); + instantiationService.stub(IAuthenticationMcpService, { + getAccountPreference: () => undefined, + }); + instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(ILogService, logService); + + const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { + resource: 'https://mcp.example.com', + authorization_servers: ['not-a-valid-authorization-server'], + }, { + allowInteraction: false, + logPrefix: '[AgentHost]', + mcpServerId: 'server-id', + mcpServerName: 'Example', + mcpServerUrl: 'https://mcp.example.com', + scopes: [], + authenticate: async () => { }, + }); + + assert.deepStrictEqual({ result, warnings }, { + result: false, + warnings: [], + }); + }); }); suite('authenticateProtectedResources', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts index 3ea4ca37c16f30..8b105d7e503742 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolSetsContribution.test.ts @@ -26,24 +26,21 @@ suite('ToolSetsContribution', () => { return store.add(instaService.createInstance(LanguageModelToolsService)); } - test('ClientToolSetsContribution omits VS Code API from Agent Host tools', () => { + test('ClientToolSetsContribution omits removed tools from vscode-general', () => { const toolsService = createToolsService(); - const toolSearch: IToolData = { - id: 'toolSearch', - modelDescription: 'Search for tools', - displayName: 'Tool Search', - toolReferenceName: 'toolSearch', + const makeTool = (name: string): IToolData => ({ + id: name, + modelDescription: name, + displayName: name, + toolReferenceName: name, source: ToolDataSource.Internal, - }; - const vscodeAPI: IToolData = { - id: 'vscodeAPI', - modelDescription: 'Search VS Code API documentation', - displayName: 'VS Code API', - toolReferenceName: 'vscodeAPI', - source: ToolDataSource.Internal, - }; - store.add(toolsService.registerToolData(toolSearch)); - store.add(toolsService.registerToolData(vscodeAPI)); + }); + const toolSearch = makeTool('toolSearch'); + const removed = ['extensions', 'installExtension', 'newWorkspace', 'runCommand', 'vscodeAPI'].map(makeTool); + for (const tool of [toolSearch, ...removed]) { + store.add(toolsService.registerToolData(tool)); + } + const workspaceService = new class extends mock() { override readonly isSessionsWindow = true; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts index c059121d538491..db0153cc1dc94c 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/documentWithAnnotatedEdits.ts @@ -146,7 +146,7 @@ export class InlineSuggestEditSource extends EditSourceBase { public readonly type: 'word' | 'line' | undefined, ) { super(); } - override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.type}`; } + override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.providerId}/${this.type}`; } public getColor(): string { return '#00ff0033'; } } @@ -330,4 +330,3 @@ export function createDocWithJustReason(docWithAnnotatedEdits: IDocumentWithAnno }; return docWithJustReason; } - diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts new file mode 100644 index 00000000000000..dfdc16be6c15d3 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentAdapters.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { runOnChange } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IObservableDocument, StringEditWithReason } from './observableWorkspace.js'; +import { + IUnifiedDocumentRegistryResult, + UnifiedDocumentRegistry, +} from './unifiedDocumentRegistry.js'; +import { UnifiedDocumentAgentTransitionKind } from './unifiedDocumentReconciler.js'; + +export type UnifiedDocumentModelAdapterInputKind = 'connected' | 'edit' | 'reloadFromDisk' | 'disconnected'; + +export interface IUnifiedDocumentModelAdapterResult { + readonly inputKind: UnifiedDocumentModelAdapterInputKind; + readonly result: IUnifiedDocumentRegistryResult; +} + +/** + * Translates one observable model into unified registry inputs. + */ +export class UnifiedDocumentModelAdapter extends Disposable { + private _isDisposed = false; + + constructor( + private readonly _registry: UnifiedDocumentRegistry, + private readonly _document: IObservableDocument, + initialDiskContent: string, + private readonly _isDirty: () => boolean, + private readonly _toSource: (change: StringEditWithReason) => TSource, + private readonly _onResult: (result: IUnifiedDocumentModelAdapterResult) => void, + ) { + super(); + this._onResult({ + inputKind: 'connected', + result: this._registry.modelConnected( + this._document.uri, + initialDiskContent, + { content: this._document.value.get().value, dirty: this._isDirty() }, + ), + }); + + this._register(runOnChange(this._document.value, (value, previousValue, changes) => { + let before = previousValue.value; + for (const change of changes) { + const after = change.apply(before); + const inputKind = change.reason.metadata.source === 'reloadFromDisk' ? 'reloadFromDisk' : 'edit'; + this._onResult({ + inputKind, + result: this._registry.modelEdit(this._document.uri, { + before, + after, + source: this._toSource(change), + kind: inputKind === 'reloadFromDisk' ? 'reloadFromDisk' : 'model', + dirty: this._isDirty(), + }), + }); + before = after; + } + if (before !== value.value) { + throw new Error(`Unified document model adapter produced ${JSON.stringify(before)}, expected ${JSON.stringify(value.value)}`); + } + })); + } + + override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + super.dispose(); + this._onResult({ + inputKind: 'disconnected', + result: this._registry.modelDisconnected(this._document.uri), + }); + } +} + +export interface IUnifiedDocumentAgentEdit { + readonly resource: URI; + readonly previousResource?: URI; + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly correlation: string; + readonly kind: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentAgentAdapterResult { + readonly transitionResult: IUnifiedDocumentRegistryResult; + readonly transferResult?: IUnifiedDocumentRegistryResult; +} + +/** + * Applies one normalized Agent Host edit to the unified registry. + */ +export function applyUnifiedDocumentAgentEdit( + registry: UnifiedDocumentRegistry, + edit: IUnifiedDocumentAgentEdit, +): IUnifiedDocumentAgentAdapterResult { + const transitionResource = edit.kind === 'rename' && edit.previousResource ? edit.previousResource : edit.resource; + const transitionResult = registry.agentTransition(transitionResource, { + before: edit.before, + after: edit.after, + source: edit.source, + correlation: edit.correlation, + kind: edit.kind, + }); + if ( + edit.kind !== 'rename' || + !edit.previousResource || + transitionResult.outcome === 'conflict' || + transitionResult.outcome === 'skippedDirty' + ) { + return { transitionResult }; + } + + return { + transitionResult, + transferResult: registry.transfer(edit.previousResource, edit.resource), + }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts new file mode 100644 index 00000000000000..cc9d4e9719462f --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentReconciler.ts @@ -0,0 +1,395 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type UnifiedDocumentReconcileOutcome = 'applied' | 'duplicate' | 'conflict' | 'skippedDirty'; + +export type UnifiedDocumentTransitionKind = 'model' | 'reloadFromDisk' | 'agentHost' | 'diskSnapshot'; + +export type UnifiedDocumentAgentTransitionKind = 'create' | 'edit' | 'delete' | 'rename'; + +export interface IUnifiedDocumentModelState { + readonly content: string; + readonly dirty: boolean; +} + +export interface IUnifiedDocumentModelEdit { + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly kind: 'model' | 'reloadFromDisk'; + readonly dirty: boolean; +} + +export interface IUnifiedDocumentAgentTransition { + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly correlation: string; + readonly kind: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentTransition { + readonly id: number; + readonly before: string; + readonly after: string; + readonly source: TSource; + readonly kind: UnifiedDocumentTransitionKind; + readonly correlation?: string; + readonly agentKind?: UnifiedDocumentAgentTransitionKind; +} + +export interface IUnifiedDocumentTransitionChange { + readonly kind: 'append' | 'replace'; + readonly transition: IUnifiedDocumentTransition; +} + +export interface IUnifiedDocumentSnapshot { + readonly initialContent: string; + readonly content: string; + readonly diskContent: string; + readonly model: IUnifiedDocumentModelState | undefined; + readonly pendingReload: IUnifiedDocumentTransition | undefined; + readonly transitions: readonly IUnifiedDocumentTransition[]; +} + +export interface IUnifiedDocumentReconcileResult { + readonly outcome: UnifiedDocumentReconcileOutcome; + readonly changes: readonly IUnifiedDocumentTransitionChange[]; + readonly snapshot: IUnifiedDocumentSnapshot; +} + +/** + * Reconciles model, Agent Host, and disk observations into one canonical edit sequence. + */ +export class UnifiedDocumentReconciler { + private readonly _initialContent: string; + private _content: string; + private _diskContent: string; + private _model: IUnifiedDocumentModelState | undefined; + private _pendingReload: IUnifiedDocumentTransition | undefined; + private readonly _transitions: IUnifiedDocumentTransition[] = []; + private readonly _agentCorrelations = new Map(); + private _nextTransitionId = 1; + + constructor( + initialContent: string, + private readonly _externalSource: TSource, + ) { + this._initialContent = initialContent; + this._content = initialContent; + this._diskContent = initialContent; + } + + modelConnected(state: IUnifiedDocumentModelState): IUnifiedDocumentReconcileResult { + if (this._model) { + return this._result('conflict'); + } + + this._model = { ...state }; + if (state.content === this._content) { + return this._result('applied'); + } + if (state.dirty) { + return this._result('skippedDirty'); + } + const latestAgentTransition = this._findLatestTransition('agentHost'); + if ( + latestAgentTransition?.before === state.content && + latestAgentTransition.after === this._content && + this._diskContent === this._content + ) { + return this._result('applied'); + } + if (state.content !== this._diskContent) { + return this._result('conflict'); + } + + const changes = this._commitPendingReload(); + changes.push(this._appendTransition(this._content, state.content, this._externalSource, 'diskSnapshot')); + this._content = state.content; + return this._result('applied', changes); + } + + modelDisconnected(): IUnifiedDocumentReconcileResult { + if (!this._model) { + return this._result('duplicate'); + } + this._model = undefined; + return this._result('applied'); + } + + modelEdit(edit: IUnifiedDocumentModelEdit): IUnifiedDocumentReconcileResult { + if (!this._model || this._model.content !== edit.before) { + return this._result('conflict'); + } + + if (edit.kind === 'reloadFromDisk') { + const matchingAgentTransition = this._findTransition(edit.before, edit.after, 'agentHost'); + if (matchingAgentTransition && this._content === edit.after && this._diskContent === edit.after) { + this._model = { content: edit.after, dirty: edit.dirty }; + this._diskContent = edit.after; + return this._result('duplicate'); + } + const matchingExternalTransition = this._findExternalTransition(edit.before, edit.after); + if (matchingExternalTransition && this._content === edit.after && this._diskContent === edit.after) { + this._model = { content: edit.after, dirty: edit.dirty }; + return this._result('duplicate'); + } + if (this._pendingReload && isSameContentTransition(this._pendingReload, edit)) { + this._model = { content: edit.after, dirty: edit.dirty }; + this._diskContent = edit.after; + this._content = edit.after; + return this._result('duplicate'); + } + } + + const changes = this._commitPendingReload(); + if (this._content !== edit.before) { + this._model = { content: edit.after, dirty: edit.dirty }; + if (edit.kind === 'reloadFromDisk') { + this._diskContent = edit.after; + } + return this._result('conflict', changes); + } + + this._model = { content: edit.after, dirty: edit.dirty }; + this._content = edit.after; + if (edit.kind === 'reloadFromDisk') { + this._diskContent = edit.after; + this._pendingReload = this._createTransition(edit.before, edit.after, edit.source, edit.kind); + return this._result('applied', changes); + } + + if (edit.before === edit.after) { + return this._result('duplicate', changes); + } + changes.push(this._appendTransition(edit.before, edit.after, edit.source, edit.kind)); + return this._result('applied', changes); + } + + agentTransition(transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentReconcileResult { + const correlated = this._agentCorrelations.get(transition.correlation); + if (correlated) { + return this._result( + correlated.before === transition.before && correlated.after === transition.after ? 'duplicate' : 'conflict' + ); + } + + const existingAgentTransition = this._findTransition(transition.before, transition.after, 'agentHost'); + if (existingAgentTransition && this._content === transition.after && this._diskContent === transition.after) { + this._recordAgentCorrelation(transition, 'duplicate'); + return this._result('duplicate'); + } + + const matchingExternalTransition = this._findExternalTransition(transition.before, transition.after); + if (matchingExternalTransition && this._diskContent === transition.after) { + const replacement = this._replaceWithAgentTransition(matchingExternalTransition, transition); + this._recordAgentCorrelation(transition, 'applied'); + return this._result('applied', [{ kind: 'replace', transition: replacement }]); + } + + if (this._pendingReload && isSameContentTransition(this._pendingReload, transition)) { + const pendingReload = this._pendingReload; + const appliedTransition: IUnifiedDocumentTransition = { + ...pendingReload, + source: transition.source, + kind: 'agentHost', + correlation: transition.correlation, + agentKind: transition.kind, + }; + this._pendingReload = undefined; + this._transitions.push(appliedTransition); + this._recordAgentCorrelation(transition, 'applied'); + return this._result('applied', [{ kind: 'append', transition: { ...appliedTransition } }]); + } + + const changes = this._commitPendingReload(); + if (this._model?.dirty) { + if (this._diskContent === transition.before) { + this._diskContent = transition.after; + } + this._recordAgentCorrelation(transition, 'skippedDirty'); + return this._result('skippedDirty', changes); + } + if (this._diskContent !== transition.before || this._content !== transition.before) { + return this._result('conflict', changes); + } + + this._diskContent = transition.after; + this._content = transition.after; + this._recordAgentCorrelation(transition, 'applied'); + if (transition.before === transition.after) { + return this._result('applied', changes); + } + changes.push(this._appendAgentTransition(transition)); + return this._result('applied', changes); + } + + diskSnapshot(content: string): IUnifiedDocumentReconcileResult { + if (this._pendingReload && content === this._pendingReload.after) { + const changes = this._commitPendingReload(); + this._diskContent = content; + return this._result('applied', changes); + } + + const changes = this._commitPendingReload(); + if (content === this._diskContent && content === this._content) { + return this._result('duplicate', changes); + } + + const previousDiskContent = this._diskContent; + this._diskContent = content; + const isModelSave = this._model?.dirty === true && content === this._model.content; + if (isModelSave && this._model) { + this._model = { content: this._model.content, dirty: false }; + } + if (this._model?.dirty && content !== this._model.content) { + return this._result('conflict', changes); + } + if (content === this._content) { + return this._result('duplicate', changes); + } + if (!isModelSave && this._content !== previousDiskContent) { + return this._result('conflict', changes); + } + + changes.push(this._appendTransition(this._content, content, this._externalSource, 'diskSnapshot')); + this._content = content; + return this._result('applied', changes); + } + + getSnapshot(): IUnifiedDocumentSnapshot { + return { + initialContent: this._initialContent, + content: this._content, + diskContent: this._diskContent, + model: this._model ? { ...this._model } : undefined, + pendingReload: this._pendingReload ? { ...this._pendingReload } : undefined, + transitions: this._transitions.map(transition => ({ ...transition })), + }; + } + + private _commitPendingReload(): IUnifiedDocumentTransitionChange[] { + if (!this._pendingReload) { + return []; + } + const transition = this._pendingReload; + this._pendingReload = undefined; + this._transitions.push(transition); + return [{ kind: 'append', transition: { ...transition } }]; + } + + private _appendAgentTransition(transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentTransitionChange { + return this._appendTransition(transition.before, transition.after, transition.source, 'agentHost', transition.correlation, transition.kind); + } + + private _appendTransition( + before: string, + after: string, + source: TSource, + kind: UnifiedDocumentTransitionKind, + correlation?: string, + agentKind?: UnifiedDocumentAgentTransitionKind, + ): IUnifiedDocumentTransitionChange { + const transition = this._createTransition(before, after, source, kind, correlation, agentKind); + this._transitions.push(transition); + return { kind: 'append', transition: { ...transition } }; + } + + private _createTransition( + before: string, + after: string, + source: TSource, + kind: UnifiedDocumentTransitionKind, + correlation?: string, + agentKind?: UnifiedDocumentAgentTransitionKind, + ): IUnifiedDocumentTransition { + return { + id: this._nextTransitionId++, + before, + after, + source, + kind, + correlation, + agentKind, + }; + } + + private _findTransition(before: string, after: string, kind: UnifiedDocumentTransitionKind): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if (transition.kind === kind && transition.before === before && transition.after === after) { + return transition; + } + } + return undefined; + } + + private _findLatestTransition(kind: UnifiedDocumentTransitionKind): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if (transition.kind === kind) { + return transition; + } + } + return undefined; + } + + private _findExternalTransition(before: string, after: string): IUnifiedDocumentTransition | undefined { + for (let index = this._transitions.length - 1; index >= 0; index--) { + const transition = this._transitions[index]; + if ( + (transition.kind === 'reloadFromDisk' || transition.kind === 'diskSnapshot') && + transition.before === before && + transition.after === after + ) { + return transition; + } + } + return undefined; + } + + private _replaceWithAgentTransition( + existing: IUnifiedDocumentTransition, + agentTransition: IUnifiedDocumentAgentTransition, + ): IUnifiedDocumentTransition { + const replacement: IUnifiedDocumentTransition = { + ...existing, + source: agentTransition.source, + kind: 'agentHost', + correlation: agentTransition.correlation, + agentKind: agentTransition.kind, + }; + const index = this._transitions.findIndex(transition => transition.id === existing.id); + this._transitions[index] = replacement; + return replacement; + } + + private _recordAgentCorrelation(transition: IUnifiedDocumentAgentTransition, outcome: UnifiedDocumentReconcileOutcome): void { + this._agentCorrelations.set(transition.correlation, { + before: transition.before, + after: transition.after, + outcome, + }); + } + + private _result( + outcome: UnifiedDocumentReconcileOutcome, + changes: readonly IUnifiedDocumentTransitionChange[] = [], + ): IUnifiedDocumentReconcileResult { + return { + outcome, + changes, + snapshot: this.getSnapshot(), + }; + } +} + +function isSameContentTransition( + first: { readonly before: string; readonly after: string }, + second: { readonly before: string; readonly after: string }, +): boolean { + return first.before === second.before && first.after === second.after; +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts new file mode 100644 index 00000000000000..874a800dd96c1f --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/helpers/unifiedDocumentRegistry.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { + IUnifiedDocumentAgentTransition, + IUnifiedDocumentModelEdit, + IUnifiedDocumentModelState, + IUnifiedDocumentReconcileResult, + UnifiedDocumentReconcileOutcome, + UnifiedDocumentReconciler, +} from './unifiedDocumentReconciler.js'; + +export interface IUnifiedDocumentRegistryOptions { + readonly externalSource: TSource; + readonly canonicalize: (resource: URI) => URI; + readonly getComparisonKey: (resource: URI) => string; +} + +export interface IUnifiedDocumentRegistryEntry { + readonly resource: URI; + readonly reconciler: UnifiedDocumentReconciler; +} + +export interface IUnifiedDocumentRegistryResult { + readonly outcome: UnifiedDocumentReconcileOutcome; + readonly resource: URI; + readonly reconcileResult?: IUnifiedDocumentReconcileResult; +} + +class UnifiedDocumentRegistryEntry implements IUnifiedDocumentRegistryEntry { + constructor( + public resource: URI, + readonly reconciler: UnifiedDocumentReconciler, + ) { } +} + +/** + * Owns one unified reconciler per canonical resource identity. + */ +export class UnifiedDocumentRegistry { + private readonly _entries = new Map>(); + + constructor(private readonly _options: IUnifiedDocumentRegistryOptions) { } + + get size(): number { + return this._entries.size; + } + + get(resource: URI): IUnifiedDocumentRegistryEntry | undefined { + return this._entries.get(this._key(resource)); + } + + entries(): readonly IUnifiedDocumentRegistryEntry[] { + return Array.from(this._entries.values()); + } + + modelConnected(resource: URI, initialContent: string, state: IUnifiedDocumentModelState): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, initialContent); + return this._wrap(entry, entry.reconciler.modelConnected(state)); + } + + modelDisconnected(resource: URI): IUnifiedDocumentRegistryResult { + const canonicalResource = this._canonicalize(resource); + const entry = this._entries.get(this._key(canonicalResource)); + if (!entry) { + return { outcome: 'duplicate', resource: canonicalResource }; + } + return this._wrap(entry, entry.reconciler.modelDisconnected()); + } + + modelEdit(resource: URI, edit: IUnifiedDocumentModelEdit): IUnifiedDocumentRegistryResult { + const canonicalResource = this._canonicalize(resource); + const entry = this._entries.get(this._key(canonicalResource)); + if (!entry) { + return { outcome: 'conflict', resource: canonicalResource }; + } + return this._wrap(entry, entry.reconciler.modelEdit(edit)); + } + + agentTransition(resource: URI, transition: IUnifiedDocumentAgentTransition): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, transition.before); + return this._wrap(entry, entry.reconciler.agentTransition(transition)); + } + + diskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { + const entry = this._getOrCreate(resource, content); + return this._wrap(entry, entry.reconciler.diskSnapshot(content)); + } + + transfer(previousResource: URI, resource: URI): IUnifiedDocumentRegistryResult { + const canonicalPreviousResource = this._canonicalize(previousResource); + const canonicalResource = this._canonicalize(resource); + const previousKey = this._key(canonicalPreviousResource); + const key = this._key(canonicalResource); + const entry = this._entries.get(previousKey); + if (!entry) { + return { outcome: 'conflict', resource: canonicalResource }; + } + if (previousKey === key) { + entry.resource = canonicalResource; + return { outcome: 'duplicate', resource: canonicalResource }; + } + if (this._entries.has(key)) { + return { outcome: 'conflict', resource: canonicalResource }; + } + + this._entries.delete(previousKey); + entry.resource = canonicalResource; + this._entries.set(key, entry); + return { outcome: 'applied', resource: canonicalResource }; + } + + delete(resource: URI): boolean { + return this._entries.delete(this._key(resource)); + } + + clear(): void { + this._entries.clear(); + } + + private _getOrCreate(resource: URI, initialContent: string): UnifiedDocumentRegistryEntry { + const canonicalResource = this._canonicalize(resource); + const key = this._key(canonicalResource); + let entry = this._entries.get(key); + if (!entry) { + entry = new UnifiedDocumentRegistryEntry( + canonicalResource, + new UnifiedDocumentReconciler(initialContent, this._options.externalSource), + ); + this._entries.set(key, entry); + } + return entry; + } + + private _canonicalize(resource: URI): URI { + return this._options.canonicalize(resource); + } + + private _key(resource: URI): string { + return this._options.getComparisonKey(this._canonicalize(resource)); + } + + private _wrap( + entry: UnifiedDocumentRegistryEntry, + reconcileResult: IUnifiedDocumentReconcileResult, + ): IUnifiedDocumentRegistryResult { + return { + outcome: reconcileResult.outcome, + resource: entry.resource, + reconcileResult, + }; + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts new file mode 100644 index 00000000000000..14a5d99f27a288 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditSourceTracking.ts @@ -0,0 +1,349 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { extname } from '../../../../../base/common/path.js'; +import { autorun, derived, IObservable, IReader, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/protocol/common/actions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ToolResultContentType, type ToolResultFileEditContent } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ISCMService } from '../../../scm/common/scm.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { EditTelemetryTrigger } from './editSourceTelemetry.js'; +import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; + +const MAX_TRACKED_FILE_SIZE = 5 * 1024 * 1024; + +type GetRepo = (resource: URI, reader: IReader) => IScmRepoAdapter | undefined; + +/** + * Tracks long-term Agent Host AI attribution for one file. + */ +export class AgentHostTrackedFile extends Disposable { + private readonly _resource: ISettableObservable; + private readonly _repo; + private _languageId = 'plaintext'; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + resource: URI, + private readonly _readCurrentText: (resource: URI) => Promise, + getRepo: GetRepo, + private readonly _logService: ILogService, + private readonly _onDidExpire: () => void, + private readonly _onDidFlush: (resource: URI, content: string, languageId: string, trigger: EditTelemetryTrigger) => void, + ) { + super(); + this._resource = observableValue(this, resource); + this._repo = derived(this, reader => getRepo(this._resource.read(reader), reader)); + + this._register(autorun(reader => { + const repo = this._repo.read(reader); + if (!repo) { + return; + } + reader.store.add(runOnChange(repo.headCommitHashObs, () => this._flushAndLog('hashChange'))); + reader.store.add(runOnChange(repo.headBranchNameObs, () => this._flushAndLog('branchChange'))); + })); + + this._register(new IntervalTimer()).cancelAndSet(() => this._expireAndLog(), 10 * 60 * 60 * 1000); + } + + get resource(): URI { + return this._resource.get(); + } + + setResource(resource: URI): void { + this._resource.set(resource, undefined); + } + + applyEdit(languageId: string): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + this._languageId = languageId; + }); + } + + flush(trigger: EditTelemetryTrigger): Promise { + return this._enqueue(async () => { + if (this._isDisposed) { + return; + } + const currentText = await this._readCurrentText(this.resource); + if (currentText === undefined || this._isDisposed) { + return; + } + this._onDidFlush(this.resource, currentText, this._languageId, trigger); + }); + } + + private _enqueue(operation: () => Promise): Promise { + const result = this._operationQueue.then(operation, operation); + this._operationQueue = result.then(() => undefined, () => undefined); + return result; + } + + private _flushAndLog(trigger: EditTelemetryTrigger): void { + this.flush(trigger).catch(error => this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`)); + } + + private _expireAndLog(): void { + this.flush('10hours').then(() => this._onDidExpire(), error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to flush ${this.resource.toString()}: ${error}`); + }); + } + + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } +} + +/** + * Converts Agent Host file-edit actions into workbench edit-source telemetry. + */ +export class AgentHostEditSourceTracking extends Disposable { + private readonly _connectionListeners = this._register(new MutableDisposable()); + private readonly _trackedFiles = new Map(); + private readonly _scmAdapter: ScmAdapter; + private _operationQueue: Promise = Promise.resolve(); + private _isDisposed = false; + + constructor( + private readonly _detailsEnabled: IObservable, + private readonly _unifiedTracking: UnifiedEditSourceTracking, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, + @IFileService private readonly _fileService: IFileService, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + @ITextFileService private readonly _textFileService: ITextFileService, + @ISCMService scmService: ISCMService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._scmAdapter = new ScmAdapter(scmService); + this._syncConnectionListeners(); + this._register(this._connectionsService.onDidChangeConnections(() => this._syncConnectionListeners())); + this._register(autorun(reader => { + if (!this._detailsEnabled.read(reader)) { + this._clearTrackedFiles(); + } + })); + } + + private _syncConnectionListeners(): void { + const store = new DisposableStore(); + for (const connectionInfo of this._connectionsService.connections) { + const connection = connectionInfo.connection; + if (!connection) { + continue; + } + store.add(connection.onDidAction(envelope => { + const action = envelope.action; + if (!this._detailsEnabled.get() || action.type !== ActionType.ChatToolCallComplete || !isAhpChatChannel(envelope.channel.toString())) { + return; + } + this._enqueue(async () => { + if (!this._detailsEnabled.get()) { + return; + } + const session = URI.parse(parseRequiredSessionUriFromChatUri(envelope.channel)); + const provider = AgentSession.provider(session); + if (!provider) { + return; + } + for (const [contentIndex, content] of (action.result.content ?? []).entries()) { + if (content.type === ToolResultContentType.FileEdit) { + await this._processFileEdit(connectionInfo.authority, session, provider, action.turnId, action.toolCallId, contentIndex, content); + } + } + }); + })); + } + this._connectionListeners.value = store; + } + + private async _processFileEdit( + connectionAuthority: string, + session: URI, + provider: string, + turnId: string, + toolCallId: string, + contentIndex: number, + fileEdit: ToolResultFileEditContent, + ): Promise { + const normalized = normalizeFileEdit(fileEdit); + if (!normalized) { + return; + } + + const resource = toAgentHostUri(normalized.resource, connectionAuthority); + if (extname(resource.path).toLowerCase() === '.ipynb') { + return; + } + const editedResources = [normalized.beforeUri, normalized.afterUri] + .filter(resource => resource !== undefined) + .map(resource => toAgentHostUri(resource, connectionAuthority)); + const dirtyResource = editedResources.find(resource => isDirtyOpenTextModel(resource, this._modelService, this._textFileService)); + + const beforeText = normalized.beforeContentUri ? await this._readSnapshot(normalized.beforeContentUri, connectionAuthority) : ''; + const afterText = normalized.afterContentUri ? await this._readSnapshot(normalized.afterContentUri, connectionAuthority) : ''; + if (this._isDisposed || !this._detailsEnabled.get() || beforeText === undefined || afterText === undefined || Math.max(beforeText.length, afterText.length) > MAX_TRACKED_FILE_SIZE) { + return; + } + + const harness = provider; + const agentSessionId = AgentSession.id(session); + const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(resource, firstLine(afterText || beforeText)) ?? 'plaintext'; + const source = EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: agentSessionId, + requestId: turnId, + languageId, + mode: undefined, + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness, + origin: 'agentHost', + }); + const beforeResource = normalized.beforeUri ? toAgentHostUri(normalized.beforeUri, connectionAuthority) : undefined; + this._unifiedTracking.applyAgentEdit({ + resource, + previousResource: beforeResource, + before: beforeText, + after: afterText, + source, + correlation: `${session.toString()}:${toolCallId}:${contentIndex}`, + kind: normalized.kind, + }); + if (dirtyResource) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping attribution for dirty open file ${dirtyResource.toString()}`); + return; + } + + const resourceKey = this._uriIdentityService.extUri.getComparisonKey(resource); + const beforeResourceKey = beforeResource ? this._uriIdentityService.extUri.getComparisonKey(beforeResource) : undefined; + let trackedFile = this._trackedFiles.get(resourceKey); + if (!trackedFile && beforeResourceKey && beforeResourceKey !== resourceKey) { + trackedFile = this._trackedFiles.get(beforeResourceKey); + if (trackedFile) { + this._trackedFiles.delete(beforeResourceKey); + this._trackedFiles.set(resourceKey, trackedFile); + trackedFile.setResource(resource); + } + } + if (!trackedFile) { + const createdTrackedFile = new AgentHostTrackedFile( + resource, + currentResource => this._readCurrentText(currentResource), + (repoResource, reader) => this._scmAdapter.getRepo(repoResource, reader), + this._logService, + () => this._removeTrackedFile(createdTrackedFile), + (currentResource, content, currentLanguageId, trigger) => this._flushAgentHostResource(currentResource, content, currentLanguageId, trigger), + ); + trackedFile = createdTrackedFile; + this._trackedFiles.set(resourceKey, trackedFile); + } + this._unifiedTracking.retainAgentResource(resource); + + await trackedFile.applyEdit(languageId); + } + + private _flushAgentHostResource(resource: URI, content: string, languageId: string, trigger: EditTelemetryTrigger): void { + this._unifiedTracking.applyDiskSnapshot(resource, content); + if (this._unifiedTracking.hasLocalLongTermResource(resource)) { + return; + } + this._unifiedTracking.flushLongTermDetails(resource, trigger, languageId).catch(error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to flush unified long-term details: ${error}`); + }); + } + + private async _readSnapshot(resource: URI, connectionAuthority: string): Promise { + return this._readText(toAgentHostUri(resource, connectionAuthority), false); + } + + private async _readCurrentText(resource: URI): Promise { + return this._readText(resource, true); + } + + private async _readText(resource: URI, missingAsEmpty: boolean): Promise { + try { + const value = (await this._fileService.readFile(resource)).value.toString(); + if (value.includes('\0')) { + this._logService.trace(`[AgentHostEditSourceTracking] Skipping binary file ${resource.toString()}`); + return undefined; + } + return value; + } catch (error) { + if (missingAsEmpty && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return ''; + } + throw error; + } + } + + private _enqueue(operation: () => Promise): void { + const run = async () => { + if (!this._isDisposed) { + await operation(); + } + }; + const result = this._operationQueue.then(run, run); + this._operationQueue = result.catch(error => { + this._logService.error(`[AgentHostEditSourceTracking] Failed to process Agent Host edit: ${error}`); + }); + } + + private _removeTrackedFile(trackedFile: AgentHostTrackedFile): void { + for (const [key, value] of this._trackedFiles) { + if (value === trackedFile) { + this._trackedFiles.delete(key); + this._unifiedTracking.releaseAgentResource(trackedFile.resource); + trackedFile.dispose(); + return; + } + } + } + + private _clearTrackedFiles(): void { + for (const trackedFile of this._trackedFiles.values()) { + this._unifiedTracking.releaseAgentResource(trackedFile.resource); + trackedFile.dispose(); + } + this._trackedFiles.clear(); + } + + override dispose(): void { + this._isDisposed = true; + this._clearTrackedFiles(); + super.dispose(); + } +} + +function firstLine(text: string): string { + const lineBreak = text.search(/\r\n|\r|\n/); + return lineBreak === -1 ? text : text.substring(0, lineBreak); +} + +export function isDirtyOpenTextModel(resource: URI, modelService: Pick, textFileService: Pick): boolean { + return modelService.getModel(resource) !== null && textFileService.isDirty(resource); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts index 387563fbd4af28..eb5c36319f4664 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetryReporter.ts @@ -9,7 +9,7 @@ import { BaseStringEdit } from '../../../../../editor/common/core/edits/stringEd import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ArcTracker } from '../../common/arcTracker.js'; -import type { ScmRepoAdapter } from './scmAdapter.js'; +import type { IScmRepoAdapter } from './scmAdapter.js'; export class ArcTelemetryReporter extends Disposable { private readonly _arcTracker; @@ -22,7 +22,7 @@ export class ArcTelemetryReporter extends Disposable { private readonly _documentValueBeforeTrackedEdit: StringText, private readonly _document: { value: IObservableWithChange }, // _markedEdits -> document.value - private readonly _gitRepo: IObservable, + private readonly _gitRepo: IObservable, private readonly _trackedEdit: BaseStringEdit, private readonly _sendTelemetryEvent: (res: ArcTelemetryReporterData) => void, private readonly _dispose: () => void, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts index a0abb90038473d..263aad985d0055 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts @@ -11,7 +11,7 @@ import { EditDeltaInfo, EditSuggestionId, ITextModelEditSourceMetadata } from '. import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditSourceData, IDocumentWithAnnotatedEdits, createDocWithJustReason } from '../helpers/documentWithAnnotatedEdits.js'; import { IAiEditTelemetryService } from './aiEditTelemetry/aiEditTelemetryService.js'; -import type { ScmRepoAdapter } from './scmAdapter.js'; +import type { IScmRepoAdapter } from './scmAdapter.js'; import { forwardToChannelIf, isCopilotLikeExtension } from '../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; import { ProviderId } from '../../../../../editor/common/languages.js'; import { ArcTelemetryReporter } from './arcTelemetryReporter.js'; @@ -20,7 +20,7 @@ import { IRandomService } from '../randomService.js'; export class EditTelemetryReportInlineEditArcSender extends Disposable { constructor( docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, - scmRepoBridge: IObservable, + scmRepoBridge: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); @@ -154,7 +154,7 @@ export class CreateSuggestionIdForChatOrInlineChatCaller extends Disposable { export class EditTelemetryReportEditArcForChatOrInlineChatSender extends Disposable { constructor( docWithAnnotatedEdits: IDocumentWithAnnotatedEdits, - scmRepoBridge: IObservable, + scmRepoBridge: IObservable, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IRandomService private readonly _randomService: IRandomService, ) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts new file mode 100644 index 00000000000000..05c232371c0724 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTelemetry.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { forwardToChannelIf } from '../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; + +export type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; +export type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; + +export interface IEditSourcesDetailsTelemetryData { + mode: EditTelemetryMode; + sourceKey: string; + sourceKeyCleaned: string; + extensionId: string | undefined; + extensionVersion: string | undefined; + modelId: string | undefined; + trigger: EditTelemetryTrigger; + languageId: string; + statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; + origin: string | undefined; + harness: string | undefined; + modifiedCount: number; + deltaModifiedCount: number; + totalModifiedCount: number; +} + +type EditSourcesDetailsTelemetryClassification = { + owner: 'hediet'; + comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; + mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; + sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; + sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; + modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; + origin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The system that observed and attributed the edit.' }; + harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent harness that produced the edit.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; + deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; + totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; +}; + +export function sendEditSourcesDetailsTelemetry(telemetryService: ITelemetryService, data: IEditSourcesDetailsTelemetryData, forwardToGitHub?: boolean): void { + telemetryService.publicLog2('editTelemetry.editSources.details', { + ...data, + ...(forwardToGitHub === undefined ? {} : forwardToChannelIf(forwardToGitHub)), + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts index 2a7e05d78b8a84..ec289515446e43 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingFeature.ts @@ -27,6 +27,8 @@ import { DataChannelForwardingTelemetryService } from '../../../../../platform/d import { EDIT_TELEMETRY_DETAILS_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from '../settings.js'; import { VSCodeWorkspace } from '../helpers/vscodeObservableWorkspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { AgentHostEditSourceTracking } from './agentHostEditSourceTracking.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; export class EditTrackingFeature extends Disposable { @@ -68,7 +70,9 @@ export class EditTrackingFeature extends Disposable { const instantiationServiceWithInterceptedTelemetry = this._instantiationService.createChild(new ServiceCollection( [ITelemetryService, this._instantiationService.createInstance(DataChannelForwardingTelemetryService)] )); - const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments)); + const unifiedTracking = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(UnifiedEditSourceTracking, this._workspace)); + const impl = this._register(instantiationServiceWithInterceptedTelemetry.createInstance(EditSourceTrackingImpl, shouldSendDetails, this._annotatedDocuments, unifiedTracking)); + this._register(instantiationServiceWithInterceptedTelemetry.createInstance(AgentHostEditSourceTracking, shouldSendDetails, unifiedTracking)); this._register(autorun((reader) => { if (!this._editSourceTrackingShowDecorations.read(reader)) { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 55fb2fdf16f967..0b1b7b401f023a 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -15,11 +15,28 @@ import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArc import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js'; import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js'; import { sumByCategory } from '../helpers/utils.js'; -import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js'; +import { IScmRepoAdapter, ScmAdapter } from './scmAdapter.js'; import { IRandomService } from '../randomService.js'; +import { EditTelemetryMode, EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { UnifiedEditSourceTracking } from './unifiedEditSourceTracking.js'; -type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWindow'; -type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; +export type EditTelemetryCategory = 'nes' | 'inlineCompletionsCopilot' | 'inlineCompletionsNES' | 'inlineCompletionsOther' | 'otherAI' | 'user' | 'ide' | 'external' | 'unknown'; + +export function getEditTelemetryCategory(source: EditSource): EditTelemetryCategory { + if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; } + + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; } + if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; } + if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; } + + if (source.category === 'ai') { return 'otherAI'; } + if (source.category === 'user') { return 'user'; } + if (source.category === 'ide') { return 'ide'; } + if (source.category === 'external') { return 'external'; } + return 'unknown'; +} export class EditSourceTrackingImpl extends Disposable { public readonly docsState; @@ -28,13 +45,20 @@ export class EditSourceTrackingImpl extends Disposable { constructor( private readonly _statsEnabled: IObservable, private readonly _annotatedDocuments: IAnnotatedDocuments, + private readonly _unifiedTracking: UnifiedEditSourceTracking | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); const scmBridge = this._instantiationService.createInstance(ScmAdapter); this._states = mapObservableArrayCached(this, this._annotatedDocuments.documents, (doc, store) => { - return [doc.document, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, scmBridge, this._statsEnabled))] as const; + return [doc.document, store.add(this._instantiationService.createInstance( + TrackedDocumentInfo, + doc, + scmBridge, + this._statsEnabled, + this._unifiedTracking, + ))] as const; }); this.docsState = this._states.map((entries) => new Map(entries)); @@ -47,20 +71,23 @@ class TrackedDocumentInfo extends Disposable { public readonly windowedTracker: IObservable | undefined>; public readonly windowedFocusTracker: IObservable | undefined>; - private readonly _repo: IObservable; + private readonly _repo: IObservable; constructor( private readonly _doc: AnnotatedDocument, private readonly _scm: ScmAdapter, private readonly _statsEnabled: IObservable, + private readonly _unifiedTracking: UnifiedEditSourceTracking | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRandomService private readonly _randomService: IRandomService, @IUserAttentionService private readonly _userAttentionService: IUserAttentionService, + @ILogService private readonly _logService: ILogService, ) { super(); this._repo = derived(this, reader => this._scm.getRepo(_doc.document.uri, reader)); + this._unifiedTracking?.retainLocalLongTermResource(this._doc.document.uri); const docWithJustReason = createDocWithJustReason(_doc.documentWithAnnotations, this._store); @@ -75,9 +102,11 @@ class TrackedDocumentInfo extends Disposable { const startFocusTime = this._userAttentionService.totalFocusTimeMs; const startTime = Date.now(); reader.store.add(toDisposable(() => { + const statsUuid = this._randomService.generateUuid(); + this._flushLongTermDetails(longtermReason, statsUuid); // send long term document telemetry if (!t.isEmpty()) { - this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime); + this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime, statsUuid); } t.dispose(); })); @@ -170,7 +199,29 @@ class TrackedDocumentInfo extends Disposable { } - async sendTelemetry(mode: EditTelemetryMode, trigger: EditTelemetryTrigger, t: DocumentEditSourceTracker, focusTime: number, actualTime: number) { + private _flushLongTermDetails(trigger: EditTelemetryTrigger, statsUuid: string): void { + const unifiedTracking = this._unifiedTracking; + if (!unifiedTracking) { + return; + } + unifiedTracking.flushLongTermDetails( + this._doc.document.uri, + trigger, + this._doc.document.languageId.get(), + statsUuid, + ).catch(error => { + this._logService.error(`[EditSourceTrackingImpl] Failed to flush unified long-term details: ${error}`); + }); + } + + async sendTelemetry( + mode: EditTelemetryMode, + trigger: EditTelemetryTrigger, + t: DocumentEditSourceTracker, + focusTime: number, + actualTime: number, + statsUuid = this._randomService.generateUuid(), + ) { const ranges = t.getTrackedRanges(); const keys = t.getAllKeys(); if (keys.length === 0) { @@ -179,85 +230,41 @@ class TrackedDocumentInfo extends Disposable { const data = this.getTelemetryData(ranges); - const statsUuid = this._randomService.generateUuid(); - - const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); - const entries = Object.entries(sums).filter(([key, value]) => value !== undefined); - entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator))); - entries.length = mode === 'longterm' ? 30 : 10; - - for (const key of keys) { - if (!sums[key]) { - sums[key] = 0; + if (mode !== 'longterm' || !this._unifiedTracking) { + const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey); + for (const key of keys) { + if (!sums[key]) { + sums[key] = 0; + } } - } - - for (const [key, value] of Object.entries(sums)) { - if (value === undefined) { - continue; + const entries = Object.entries(sums) + .filter((entry): entry is [string, number] => entry[1] !== undefined) + .sort(reverseOrder(compareBy(([, value]) => value, numberComparator))) + .slice(0, mode === 'longterm' ? 30 : 10); + + for (const [key, value] of entries) { + const repr = t.getRepresentative(key)!; + const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); + + sendEditSourcesDetailsTelemetry(this._telemetryService, { + mode, + sourceKey: key, + sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), + extensionId: repr.props.$extensionId, + extensionVersion: repr.props.$extensionVersion, + modelId: repr.props.$modelId, + trigger, + languageId: this._doc.document.languageId.get(), + statsUuid, + conversationId: repr.props.$$sessionId, + requestId: repr.props.$$requestId, + origin: repr.props.$origin, + harness: repr.props.$harness, + modifiedCount: value, + deltaModifiedCount, + totalModifiedCount: data.totalModifiedCharactersInFinalState, + }); } - - const repr = t.getRepresentative(key)!; - const deltaModifiedCount = t.getTotalInsertedCharactersCount(key); - - this._telemetryService.publicLog2<{ - mode: EditTelemetryMode; - sourceKey: string; - - sourceKeyCleaned: string; - extensionId: string | undefined; - extensionVersion: string | undefined; - modelId: string | undefined; - - trigger: EditTelemetryTrigger; - languageId: string; - statsUuid: string; - conversationId: string | undefined; - requestId: string | undefined; - modifiedCount: number; - deltaModifiedCount: number; - totalModifiedCount: number; - }, { - owner: 'hediet'; - comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; - - mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'10minFocusWindow\', or \'20minFocusWindow\'.' }; - sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' }; - - sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' }; - extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; - extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; - modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; - - languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; - statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; - conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; - requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; - - trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; - - modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true }; - totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true }; - - }>('editTelemetry.editSources.details', { - mode, - sourceKey: key, - - sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }), - extensionId: repr.props.$extensionId, - extensionVersion: repr.props.$extensionVersion, - modelId: repr.props.$modelId, - - trigger, - languageId: this._doc.document.languageId.get(), - statsUuid: statsUuid, - conversationId: repr.props.$$sessionId, - requestId: repr.props.$$requestId, - modifiedCount: value, - deltaModifiedCount: deltaModifiedCount, - totalModifiedCount: data.totalModifiedCharactersInFinalState, - }); } @@ -320,25 +327,13 @@ class TrackedDocumentInfo extends Disposable { }); } - getTelemetryData(ranges: readonly TrackedEdit[]) { - const getEditCategory = (source: EditSource) => { - if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; } - - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; } - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; } - if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; } - if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; } - - if (source.category === 'ai') { return 'otherAI'; } - if (source.category === 'user') { return 'user'; } - if (source.category === 'ide') { return 'ide'; } - if (source.category === 'external') { return 'external'; } - if (source.category === 'unknown') { return 'unknown'; } - - return 'unknown'; - }; + override dispose(): void { + super.dispose(); + this._unifiedTracking?.releaseLocalLongTermResource(this._doc.document.uri); + } - const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source)); + getTelemetryData(ranges: readonly TrackedEdit[]) { + const sums = sumByCategory(ranges, r => r.range.length, r => getEditTelemetryCategory(r.source)); const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length); return { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts index 59553b0d418fda..afffaa88706151 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editTracker.ts @@ -80,6 +80,15 @@ export class DocumentEditSourceTracker extends Disposable { return this._representativePerKey.get(key); } + public applyPendingExternalEdits(): void { + if (this._pendingExternalEdits.isEmpty()) { + return; + } + this._applyEdit(this._pendingExternalEdits); + this._pendingExternalEdits = AnnotatedStringEdit.empty; + this._update.trigger(undefined); + } + public getTrackedRanges(reader?: IReader): TrackedEdit[] { this._update.read(reader); const ranges = this._edits.getNewRanges(); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts index 02b8d245137375..98738835158b97 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/scmAdapter.ts @@ -20,7 +20,7 @@ export class ScmAdapter { this._reposChangedSignal = observableSignalFromEvent(this, Event.any(this._scmService.onDidAddRepository, this._scmService.onDidRemoveRepository)); } - public getRepo(uri: URI, reader: IReader | undefined): ScmRepoAdapter | undefined { + public getRepo(uri: URI, reader: IReader | undefined): IScmRepoAdapter | undefined { this._reposChangedSignal.read(reader); const repo = this._scmService.getRepository(uri); if (!repo) { @@ -30,7 +30,13 @@ export class ScmAdapter { } } -export class ScmRepoAdapter { +export interface IScmRepoAdapter { + readonly headBranchNameObs: IObservable; + readonly headCommitHashObs: IObservable; + isIgnored(uri: URI): Promise; +} + +export class ScmRepoAdapter implements IScmRepoAdapter { public readonly headBranchNameObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.name); public readonly headCommitHashObs: IObservable = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.revision); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts new file mode 100644 index 00000000000000..e9ef041531f6c7 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedDocumentTrackerProjection.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../helpers/documentWithAnnotatedEdits.js'; +import { IUnifiedDocumentSnapshot } from '../helpers/unifiedDocumentReconciler.js'; +import { DocumentEditSourceTracker } from './editTracker.js'; + +export type UnifiedDocumentComputeDiff = (before: string, after: string) => Promise; + +export interface IEditTrackerSourceSnapshot { + readonly sourceKey: string; + readonly sourceIndex: number; + readonly retainedIndex: number | undefined; + readonly representativeKey: string; + readonly cleanedSourceKey: string; + readonly extensionId: string | undefined; + readonly extensionVersion: string | undefined; + readonly modelId: string | undefined; + readonly conversationId: string | undefined; + readonly requestId: string | undefined; + readonly origin: string | undefined; + readonly harness: string | undefined; + readonly insertedCount: number; + readonly retainedCount: number; +} + +export interface IEditTrackerRangeSnapshot { + readonly start: number; + readonly endExclusive: number; + readonly sourceKey: string; +} + +export interface IEditTrackerSnapshot { + readonly content: string; + readonly targetContent: string; + readonly hasPendingReload: boolean; + readonly totalRetainedCount: number; + readonly sources: readonly IEditTrackerSourceSnapshot[]; + readonly ranges: readonly IEditTrackerRangeSnapshot[]; +} + +export interface IEditSourceDetailsRowSnapshot { + readonly sourceKey: string; + readonly cleanedSourceKey: string; + readonly extensionId: string | undefined; + readonly extensionVersion: string | undefined; + readonly modelId: string | undefined; + readonly conversationId: string | undefined; + readonly requestId: string | undefined; + readonly origin: string | undefined; + readonly harness: string | undefined; + readonly modifiedCount: number; + readonly deltaModifiedCount: number; +} + +export interface IEditSourceDetailsSnapshot { + readonly totalModifiedCount: number; + readonly rows: readonly IEditSourceDetailsRowSnapshot[]; +} + +export type EditSourceDetailsOrder = 'tracker' | 'retained'; + +/** + * Replays a unified transition snapshot through the existing edit-source tracker. + */ +export async function projectUnifiedDocumentTracker( + snapshot: IUnifiedDocumentSnapshot, + computeDiff: UnifiedDocumentComputeDiff, +): Promise { + const store = new DisposableStore(); + try { + const document = store.add(new ProjectionDocument(snapshot.initialContent)); + const tracker = store.add(new DocumentEditSourceTracker(document, undefined)); + let content = snapshot.initialContent; + for (const transition of snapshot.transitions) { + if (transition.before !== content) { + throw new Error(`Unified transition ${transition.id} starts from unexpected content`); + } + if (transition.before !== transition.after) { + const edit = await computeDiff(transition.before, transition.after); + document.apply(edit, transition.source); + } + content = transition.after; + } + await tracker.waitForQueue(); + tracker.applyPendingExternalEdits(); + + if (snapshot.pendingReload) { + if (snapshot.pendingReload.before !== content || snapshot.pendingReload.after !== snapshot.content) { + throw new Error('Unified pending reload does not connect projected and target content'); + } + } else if (content !== snapshot.content) { + throw new Error('Unified transition replay produced unexpected content'); + } + + return snapshotDocumentEditSourceTracker(tracker, content, snapshot.content, !!snapshot.pendingReload); + } finally { + store.dispose(); + } +} + +export function snapshotDocumentEditSourceTracker( + tracker: DocumentEditSourceTracker, + content: string, + targetContent = content, + hasPendingReload = false, +): IEditTrackerSnapshot { + const ranges = tracker.getTrackedRanges().map(range => ({ + start: range.range.start, + endExclusive: range.range.endExclusive, + sourceKey: range.sourceKey, + })); + const retainedByKey = new Map(); + const retainedIndexByKey = new Map(); + for (const [rangeIndex, range] of ranges.entries()) { + retainedByKey.set(range.sourceKey, (retainedByKey.get(range.sourceKey) ?? 0) + range.endExclusive - range.start); + if (!retainedIndexByKey.has(range.sourceKey)) { + retainedIndexByKey.set(range.sourceKey, rangeIndex); + } + } + const sources = tracker.getAllKeys().map((sourceKey, sourceIndex) => { + const representative = tracker.getRepresentative(sourceKey); + return { + sourceKey, + sourceIndex, + retainedIndex: retainedIndexByKey.get(sourceKey), + representativeKey: representative?.toKey(Number.MAX_SAFE_INTEGER) ?? '', + cleanedSourceKey: representative?.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }) ?? '', + extensionId: representative?.props.$extensionId, + extensionVersion: representative?.props.$extensionVersion, + modelId: representative?.props.$modelId, + conversationId: representative?.props.$$sessionId, + requestId: representative?.props.$$requestId, + origin: representative?.props.$origin, + harness: representative?.props.$harness, + insertedCount: tracker.getTotalInsertedCharactersCount(sourceKey), + retainedCount: retainedByKey.get(sourceKey) ?? 0, + }; + }).sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)); + + return { + content, + targetContent, + hasPendingReload, + totalRetainedCount: ranges.reduce((sum, range) => sum + range.endExclusive - range.start, 0), + sources, + ranges, + }; +} + +export function snapshotEditSourceDetails( + snapshot: IEditTrackerSnapshot, + limit = 30, + order: EditSourceDetailsOrder = 'tracker', +): IEditSourceDetailsSnapshot { + const getOrder = (source: IEditTrackerSourceSnapshot) => order === 'retained' + ? source.retainedIndex ?? snapshot.ranges.length + source.sourceIndex + : source.sourceIndex; + const sources = snapshot.sources + .toSorted((left, right) => right.retainedCount - left.retainedCount || getOrder(left) - getOrder(right)) + .slice(0, limit); + return { + totalModifiedCount: snapshot.sources.reduce((sum, source) => sum + source.retainedCount, 0), + rows: sources.map(source => ({ + sourceKey: source.sourceKey, + cleanedSourceKey: source.cleanedSourceKey, + extensionId: source.extensionId, + extensionVersion: source.extensionVersion, + modelId: source.modelId, + conversationId: source.conversationId, + requestId: source.requestId, + origin: source.origin, + harness: source.harness, + modifiedCount: source.retainedCount, + deltaModifiedCount: source.insertedCount, + })), + }; +} + +class ProjectionDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialContent: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts new file mode 100644 index 00000000000000..09d54171be2839 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/unifiedEditSourceTracking.ts @@ -0,0 +1,295 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { mapObservableArrayCached } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { DiffService } from '../helpers/documentWithAnnotatedEdits.js'; +import { ObservableWorkspace } from '../helpers/observableWorkspace.js'; +import { + applyUnifiedDocumentAgentEdit, + IUnifiedDocumentAgentAdapterResult, + IUnifiedDocumentAgentEdit, + UnifiedDocumentModelAdapter, +} from '../helpers/unifiedDocumentAdapters.js'; +import { + IUnifiedDocumentRegistryResult, + UnifiedDocumentRegistry, +} from '../helpers/unifiedDocumentRegistry.js'; +import { IUnifiedDocumentSnapshot, IUnifiedDocumentTransition } from '../helpers/unifiedDocumentReconciler.js'; +import { IRandomService } from '../randomService.js'; +import { EditTelemetryTrigger, sendEditSourcesDetailsTelemetry } from './editSourceTelemetry.js'; +import { + IEditSourceDetailsSnapshot, + IEditTrackerSnapshot, + projectUnifiedDocumentTracker, + snapshotEditSourceDetails, +} from './unifiedDocumentTrackerProjection.js'; + +interface IUnifiedEditSourceTrackingCheckpoint { + readonly content: string; + readonly maxTransitionId: number; +} + +/** + * Owns the canonical edit stream and long-term details windows for each resource. + */ +export class UnifiedEditSourceTracking extends Disposable { + private readonly _registry: UnifiedDocumentRegistry; + private readonly _lastResults = new Map>(); + private readonly _longTermCheckpoints = new Map(); + private readonly _flushQueues = new Map>(); + private readonly _retainedAgentResources = new Set(); + private readonly _localLongTermResources = new Set(); + private readonly _diffService: DiffService; + + constructor( + workspace: ObservableWorkspace, + @IFileService private readonly _fileService: IFileService, + @ITextFileService private readonly _textFileService: ITextFileService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IEditorWorkerService editorWorkerService: IEditorWorkerService, + @IRandomService private readonly _randomService: IRandomService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._diffService = new DiffService(editorWorkerService); + this._registry = new UnifiedDocumentRegistry({ + externalSource: EditSources.reloadFromDisk(), + canonicalize: resource => this._uriIdentityService.asCanonicalUri(resource), + getComparisonKey: resource => this._uriIdentityService.extUri.getComparisonKey(resource), + }); + mapObservableArrayCached(this, workspace.documents, (document, store) => { + const initialContent = document.value.get().value; + return store.add(new UnifiedDocumentModelAdapter( + this._registry, + document, + initialContent, + () => this._textFileService.isDirty(document.uri), + change => change.reason, + result => { + this._recordResult(result.result); + if (result.inputKind === 'disconnected') { + this._scheduleCleanup(result.result.resource); + } + }, + )); + }).recomputeInitiallyAndOnChange(this._store); + } + + applyAgentEdit(edit: IUnifiedDocumentAgentEdit): IUnifiedDocumentAgentAdapterResult { + const result = applyUnifiedDocumentAgentEdit(this._registry, edit); + this._recordResult(result.transitionResult); + if (result.transferResult) { + if (edit.previousResource) { + this._lastResults.delete(this._key(edit.previousResource)); + this._transferResourceState(edit.previousResource, edit.resource); + } + this._recordResult(result.transferResult); + } + return result; + } + + retainAgentResource(resource: URI): void { + this._retainedAgentResources.add(this._key(resource)); + } + + releaseAgentResource(resource: URI): void { + this._retainedAgentResources.delete(this._key(resource)); + this._scheduleCleanup(resource); + } + + retainLocalLongTermResource(resource: URI): void { + this._localLongTermResources.add(this._key(resource)); + } + + releaseLocalLongTermResource(resource: URI): void { + this._localLongTermResources.delete(this._key(resource)); + this._scheduleCleanup(resource); + } + + hasLocalLongTermResource(resource: URI): boolean { + return this._localLongTermResources.has(this._key(resource)); + } + + applyDiskSnapshot(resource: URI, content: string): IUnifiedDocumentRegistryResult { + const result = this._registry.diskSnapshot(resource, content); + this._recordResult(result); + return result; + } + + getLastResult(resource: URI): IUnifiedDocumentRegistryResult | undefined { + return this._lastResults.get(this._key(resource)); + } + + getSnapshot(resource: URI): IUnifiedDocumentSnapshot | undefined { + return this._registry.get(resource)?.reconciler.getSnapshot(); + } + + async project(resource: URI): Promise { + const snapshot = this.getSnapshot(resource); + return snapshot ? projectUnifiedDocumentTracker(snapshot, (before, after) => this._diffService.computeDiff(before, after)) : undefined; + } + + flushLongTermDetails( + resource: URI, + trigger: EditTelemetryTrigger, + languageId: string, + statsUuid = this._randomService.generateUuid(), + ): Promise { + const resourceKey = this._key(resource); + const run = () => this._flushLongTermDetails(resourceKey, resource, trigger, languageId, statsUuid); + const result = (this._flushQueues.get(resourceKey) ?? Promise.resolve(undefined)).then(run, run); + this._flushQueues.set(resourceKey, result); + const clearQueue = () => { + if (this._flushQueues.get(resourceKey) === result) { + this._flushQueues.delete(resourceKey); + } + }; + result.then(clearQueue, clearQueue); + return result; + } + + private async _flushLongTermDetails( + resourceKey: string, + resource: URI, + trigger: EditTelemetryTrigger, + languageId: string, + statsUuid: string, + ): Promise { + await this._synchronizeDisk(resource); + const snapshot = this.getSnapshot(resource); + if (!snapshot) { + return undefined; + } + const checkpoint = this._longTermCheckpoints.get(resourceKey); + const transitions = checkpoint + ? snapshot.transitions.filter(transition => transition.id > checkpoint.maxTransitionId) + : snapshot.transitions; + const pendingReload = snapshot.pendingReload && (!checkpoint || snapshot.pendingReload.id > checkpoint.maxTransitionId) + ? snapshot.pendingReload + : undefined; + if (transitions.length === 0) { + if (!pendingReload) { + this._longTermCheckpoints.set(resourceKey, createCheckpoint(snapshot.content, snapshot.transitions)); + } + return undefined; + } + + const windowSnapshot: IUnifiedDocumentSnapshot = { + ...snapshot, + initialContent: checkpoint?.content ?? snapshot.initialContent, + transitions, + pendingReload, + }; + const trackerSnapshot = await projectUnifiedDocumentTracker( + windowSnapshot, + (before, after) => this._diffService.computeDiff(before, after), + ); + const details = snapshotEditSourceDetails(trackerSnapshot, 30, 'retained'); + if (details.rows.length > 0) { + for (const row of details.rows) { + sendEditSourcesDetailsTelemetry(this._telemetryService, { + mode: 'longterm', + sourceKey: row.sourceKey, + sourceKeyCleaned: row.cleanedSourceKey, + extensionId: row.extensionId, + extensionVersion: row.extensionVersion, + modelId: row.modelId, + trigger, + languageId, + statsUuid, + conversationId: row.conversationId, + requestId: row.requestId, + origin: row.origin, + harness: row.harness, + modifiedCount: row.modifiedCount, + deltaModifiedCount: row.deltaModifiedCount, + totalModifiedCount: details.totalModifiedCount, + }, row.origin === 'agentHost' ? row.harness === 'copilotcli' : undefined); + } + } + this._longTermCheckpoints.set(resourceKey, createCheckpoint(trackerSnapshot.content, transitions)); + return details; + } + + private async _synchronizeDisk(resource: URI): Promise { + const snapshot = this.getSnapshot(resource); + if (snapshot?.model?.dirty || snapshot?.pendingReload || !this._fileService.hasProvider(resource)) { + return; + } + try { + const content = (await this._fileService.readFile(resource)).value.toString(); + if (!content.includes('\0')) { + this.applyDiskSnapshot(resource, content); + } + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + this.applyDiskSnapshot(resource, ''); + return; + } + throw error; + } + } + + private _recordResult(result: IUnifiedDocumentRegistryResult): void { + this._lastResults.set(this._key(result.resource), result); + } + + private _transferResourceState(previousResource: URI, resource: URI): void { + const previousKey = this._key(previousResource); + const key = this._key(resource); + const checkpoint = this._longTermCheckpoints.get(previousKey); + if (checkpoint) { + this._longTermCheckpoints.delete(previousKey); + this._longTermCheckpoints.set(key, checkpoint); + } + if (this._retainedAgentResources.delete(previousKey)) { + this._retainedAgentResources.add(key); + } + if (this._localLongTermResources.delete(previousKey)) { + this._localLongTermResources.add(key); + } + } + + private _scheduleCleanup(resource: URI): void { + const resourceKey = this._key(resource); + const pendingFlush = this._flushQueues.get(resourceKey); + (pendingFlush ?? Promise.resolve(undefined)).then(() => { + const snapshot = this.getSnapshot(resource); + if (snapshot?.model || this._retainedAgentResources.has(resourceKey) || this._localLongTermResources.has(resourceKey)) { + return; + } + this._registry.delete(resource); + this._lastResults.delete(resourceKey); + this._longTermCheckpoints.delete(resourceKey); + }, error => { + this._logService.error(`[UnifiedEditSourceTracking] Failed to finish resource cleanup: ${error}`); + }); + } + + private _key(resource: URI): string { + return this._uriIdentityService.extUri.getComparisonKey(this._uriIdentityService.asCanonicalUri(resource)); + } +} + +function createCheckpoint( + content: string, + transitions: readonly IUnifiedDocumentTransition[], +): IUnifiedEditSourceTrackingCheckpoint { + let maxTransitionId = 0; + for (const transition of transitions) { + maxTransitionId = Math.max(maxTransitionId, transition.id); + } + return { content, maxTransitionId }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts new file mode 100644 index 00000000000000..e4396c6d6df2f8 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditSourceTracking.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AgentHostTrackedFile, isDirtyOpenTextModel } from '../../browser/telemetry/agentHostEditSourceTracking.js'; + +suite('Agent Host Edit Source Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('flushes current content with the latest language', async () => { + const disposables = new DisposableStore(); + const resource = URI.file('C:\\repo\\file.ts'); + let currentText = 'alpha'; + const flushes: { resource: URI; content: string; languageId: string; trigger: string }[] = []; + const trackedFile = disposables.add(new AgentHostTrackedFile( + resource, + async () => currentText, + () => undefined, + new NullLogService(), + () => { }, + (flushResource, content, languageId, trigger) => flushes.push({ resource: flushResource, content, languageId, trigger }), + )); + + await trackedFile.applyEdit('typescript'); + await trackedFile.flush('hashChange'); + currentText = 'beta'; + await trackedFile.applyEdit('javascript'); + await trackedFile.flush('branchChange'); + + assert.deepStrictEqual(flushes, [ + { resource, content: 'alpha', languageId: 'typescript', trigger: 'hashChange' }, + { resource, content: 'beta', languageId: 'javascript', trigger: 'branchChange' }, + ]); + + disposables.dispose(); + }); + + test('only skips attribution for open dirty text models', () => { + const resource = URI.file('C:\\repo\\file.ts'); + const model = Object.create(null) as ITextModel; + + assert.deepStrictEqual({ + closedDirty: isDirtyOpenTextModel(resource, { getModel: () => null }, { isDirty: () => true }), + openClean: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => false }), + openDirty: isDirtyOpenTextModel(resource, { getModel: () => model }, { isDirty: () => true }), + }, { + closedDirty: false, + openClean: false, + openDirty: true, + }); + }); + + test('uses the transferred resource when flushing a rename', async () => { + const disposables = new DisposableStore(); + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + const reads: URI[] = []; + const flushes: URI[] = []; + const trackedFile = disposables.add(new AgentHostTrackedFile( + previousResource, + async currentResource => { + reads.push(currentResource); + return 'content'; + }, + () => undefined, + new NullLogService(), + () => { }, + currentResource => flushes.push(currentResource), + )); + + trackedFile.setResource(resource); + await trackedFile.flush('hashChange'); + + assert.deepStrictEqual({ + resource: trackedFile.resource, + reads, + flushes, + }, { + resource, + reads: [resource], + flushes: [resource], + }); + disposables.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts new file mode 100644 index 00000000000000..c9ea85b97961c6 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/documentWithAnnotatedEdits.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue, runOnChange } from '../../../../../base/common/observable.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { CombineStreamedChanges, DiffService, EditSourceData, IDocumentWithAnnotatedEdits, MinimizeEditsProcessor } from '../../browser/helpers/documentWithAnnotatedEdits.js'; + +suite('Documents with Annotated Edits', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('collapses streamed chat edits into one diff', () => runWithFakedTimers({}, async () => { + const context = setup(''); + const source = chatEdit(); + await timeout(0); + + context.document.apply(StringEdit.insert(0, 'a'), source); + await timeout(500); + context.document.apply(StringEdit.insert(1, 'b'), source); + await timeout(1100); + + assert.deepStrictEqual(context.changes, [{ + value: 'ab', + source: 'ai/chat/sidebar', + replacements: [{ start: 0, endExclusive: 0, newText: 'ab' }], + }]); + context.disposables.dispose(); + })); + + test('preserves ordering when a user edit interrupts streamed chat edits', () => runWithFakedTimers({}, async () => { + const context = setup(''); + await timeout(0); + + context.document.apply(StringEdit.insert(0, 'a'), chatEdit()); + await timeout(500); + context.document.apply(StringEdit.insert(1, 'U'), EditSources.cursor({ kind: 'type' })); + await timeout(1100); + + assert.deepStrictEqual(context.changes, [ + { + value: 'a', + source: 'ai/chat/sidebar', + replacements: [{ start: 0, endExclusive: 0, newText: 'a' }], + }, + { + value: 'aU', + source: 'user', + replacements: [{ start: 1, endExclusive: 1, newText: 'U' }], + }, + ]); + context.disposables.dispose(); + })); + + test('minimizes common prefixes and suffixes', () => { + const disposables = new DisposableStore(); + const document = disposables.add(new TestSourceDocument('hello world')); + const minimized = disposables.add(new MinimizeEditsProcessor(document)); + const changes: Array<{ value: string; replacements: Array<{ start: number; endExclusive: number; newText: string }> }> = []; + disposables.add(runOnChange(minimized.value, (value, _previous, edits) => { + const edit = AnnotatedStringEdit.compose(edits.map(change => change.edit)); + changes.push({ + value: value.value, + replacements: edit.replacements.map(replacement => ({ + start: replacement.replaceRange.start, + endExclusive: replacement.replaceRange.endExclusive, + newText: replacement.newText, + })), + }); + })); + + document.apply(StringEdit.replace(OffsetRange.ofLength(11), 'hello brave world'), chatEdit()); + + assert.deepStrictEqual(changes, [{ + value: 'hello brave world', + replacements: [{ start: 5, endExclusive: 5, newText: ' brave' }], + }]); + disposables.dispose(); + }); +}); + +function setup(initialValue: string) { + const disposables = new DisposableStore(); + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stubInstance(DiffService, { + computeDiff: async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + }); + const document = disposables.add(new TestSourceDocument(initialValue)); + const combined = disposables.add(instantiationService.createInstance(CombineStreamedChanges, document)); + const changes: Array<{ value: string; source: string; replacements: Array<{ start: number; endExclusive: number; newText: string }> }> = []; + disposables.add(runOnChange(combined.value, (value, _previous, edits) => { + const edit = AnnotatedStringEdit.compose(edits.map(change => change.edit)); + changes.push({ + value: value.value, + source: edit.replacements[0]?.data.source.toString(), + replacements: edit.replacements.map(replacement => ({ + start: replacement.replaceRange.start, + endExclusive: replacement.replaceRange.endExclusive, + newText: replacement.newText, + })), + }); + })); + return { disposables, document, changes }; +} + +class TestSourceDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialValue: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialValue)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +function chatEdit(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: 'session-1', + requestId: 'request-1', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts new file mode 100644 index 00000000000000..e5673e3df2c4b0 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceCategories.test.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ProviderId } from '../../../../../editor/common/languages.js'; +import { EditSources } from '../../../../../editor/common/textModelEditSource.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditSourceBase } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { getEditTelemetryCategory } from '../../browser/telemetry/editSourceTrackingImpl.js'; + +suite('Edit Telemetry Source Categories', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps every edit source category', () => { + const sources = { + chat: EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: undefined, + requestId: undefined, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }), + copilotCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-1', + languageId: 'typescript', + providerId: new ProviderId('github.copilot', '1.0.0', 'completions'), + correlationId: undefined, + }), + copilotChatCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-2', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'completions'), + correlationId: undefined, + }), + nes: EditSources.inlineCompletionAccept({ + nes: true, + requestUuid: 'request-3', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'nes'), + correlationId: undefined, + }), + inlineNesProvider: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-4', + languageId: 'typescript', + providerId: new ProviderId('github.copilot-chat', '1.0.0', 'nes'), + correlationId: undefined, + }), + otherCompletion: EditSources.inlineCompletionAccept({ + nes: false, + requestUuid: 'request-5', + languageId: 'typescript', + providerId: new ProviderId('other.extension', '1.0.0', 'other'), + correlationId: undefined, + }), + user: EditSources.cursor({ kind: 'type' }), + snippet: EditSources.snippet(), + format: EditSources.unknown({ name: 'formatEditsCommand' }), + external: EditSources.reloadFromDisk(), + unknown: EditSources.unknown({}), + }; + + assert.deepStrictEqual(Object.fromEntries(Object.entries(sources).map(([key, source]) => [ + key, + getEditTelemetryCategory(EditSourceBase.create(source)), + ])), { + chat: 'otherAI', + copilotCompletion: 'inlineCompletionsCopilot', + copilotChatCompletion: 'inlineCompletionsCopilot', + nes: 'nes', + inlineNesProvider: 'inlineCompletionsNES', + otherCompletion: 'inlineCompletionsOther', + user: 'user', + snippet: 'ide', + format: 'ide', + external: 'external', + unknown: 'unknown', + }); + }); +}); diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts new file mode 100644 index 00000000000000..217d1e872bceca --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js'; +import { AnnotatedDocuments, UriVisibilityProvider } from '../../browser/helpers/annotatedDocuments.js'; +import { DiffService } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { IAiEditTelemetryService } from '../../browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { EditSourceTrackingImpl } from '../../browser/telemetry/editSourceTrackingImpl.js'; +import { IScmRepoAdapter, ScmAdapter } from '../../browser/telemetry/scmAdapter.js'; +import { IRandomService } from '../../browser/randomService.js'; +import { MutableObservableWorkspace } from './editTelemetry.test.js'; + +suite('Edit Source Tracking Windows', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('flushes and recreates the long-term tracker on hash and branch changes', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + context.headHash.set('hash-2', undefined); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('alpha'), 'beta', chatEdit('request-2'))); + await timeout(1500); + context.branch.set('feature', undefined); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + modifiedCount: event.modifiedCount, + deltaModifiedCount: event.deltaModifiedCount, + })), [ + { trigger: 'hashChange', requestId: 'request-1', modifiedCount: 5, deltaModifiedCount: 5 }, + { trigger: 'branchChange', requestId: 'request-2', modifiedCount: 3, deltaModifiedCount: 3 }, + ]); + + context.disposables.dispose(); + })); + + test('flushes the long-term tracker when the document closes', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + context.document.dispose(); + await timeout(0); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + })), [{ trigger: 'closed', requestId: 'request-1' }]); + + context.disposables.dispose(); + })); + + test('flushes and recreates the long-term tracker after ten hours', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('hello'), 'alpha', chatEdit('request-1'))); + await timeout(1500); + await timeout(10 * 60 * 60 * 1000); + + context.document.applyEdit(StringEditWithReason.replace(context.document.findRange('alpha'), 'beta', chatEdit('request-2'))); + await timeout(1500); + context.headHash.set('hash-2', undefined); + + assert.deepStrictEqual(context.details.map(event => ({ + trigger: event.trigger, + requestId: event.requestId, + })), [ + { trigger: '10hours', requestId: 'request-1' }, + { trigger: 'hashChange', requestId: 'request-2' }, + ]); + + context.disposables.dispose(); + })); + + test('emits only the top thirty long-term sources by retained count', () => runWithFakedTimers({}, async () => { + const context = setup(); + await timeout(10); + + for (let i = 1; i <= 31; i++) { + context.document.applyEdit(StringEditWithReason.replace( + OffsetRange.emptyAt(context.document.value.get().value.length), + 'x'.repeat(i), + EditSources.unknown({ name: `source-${i}` }), + )); + } + await timeout(10); + context.headHash.set('hash-2', undefined); + + assert.deepStrictEqual({ + count: context.details.length, + first: context.details[0].sourceKey, + last: context.details.at(-1)?.sourceKey, + containsSmallest: context.details.some(event => event.sourceKey === 'source:unknown-name:source-1'), + }, { + count: 30, + first: 'source:unknown-name:source-31', + last: 'source:unknown-name:source-2', + containsSmallest: false, + }); + + context.disposables.dispose(); + })); + + test('starts after first visibility and keeps only the long-term tracker while hidden', () => runWithFakedTimers({}, async () => { + const visible = observableValue('visible', false); + const context = setup(visible); + await timeout(10); + + assert.strictEqual(context.impl.docsState.get().size, 0); + + visible.set(true, undefined); + const visibleState = context.impl.docsState.get().get(context.document); + if (!visibleState) { + throw new Error('Expected visible document state'); + } + assert.ok(visibleState.longtermTracker.get()); + const firstWindowedTracker = visibleState.windowedTracker.get(); + assert.ok(firstWindowedTracker); + assert.ok(visibleState.windowedFocusTracker.get()); + + visible.set(false, undefined); + const hiddenState = context.impl.docsState.get().get(context.document); + if (!hiddenState) { + throw new Error('Expected hidden document state'); + } + assert.ok(hiddenState.longtermTracker.get()); + assert.strictEqual(hiddenState.windowedTracker.get(), undefined); + assert.strictEqual(hiddenState.windowedFocusTracker.get(), undefined); + + visible.set(true, undefined); + const visibleAgainState = context.impl.docsState.get().get(context.document); + if (!visibleAgainState) { + throw new Error('Expected visible document state after reopening'); + } + assert.ok(visibleAgainState.windowedTracker.get()); + assert.notStrictEqual(visibleAgainState.windowedTracker.get(), firstWindowedTracker); + + context.disposables.dispose(); + })); +}); + +function setup(visible: ISettableObservable = observableValue('visible', true)) { + const disposables = new DisposableStore(); + const headHash = observableValue('headHash', 'hash-1'); + const branch = observableValue('branch', 'main'); + const repo = { + headCommitHashObs: headHash, + headBranchNameObs: branch, + isIgnored: async () => false, + } satisfies IScmRepoAdapter; + const details: Array<{ sourceKey: string; trigger: string; requestId: string | undefined; modifiedCount: number; deltaModifiedCount: number }> = []; + let uuid = 0; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stub(ITelemetryService, { + publicLog2(eventName, data) { + const eventData = data as { mode?: string } | undefined; + if (eventName === 'editTelemetry.editSources.details' && eventData?.mode === 'longterm') { + details.push(data as typeof details[number]); + } + }, + }); + instantiationService.stubInstance(DiffService, { computeDiff: async (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced') }); + instantiationService.stubInstance(ScmAdapter, { getRepo: () => repo }); + instantiationService.stubInstance(UriVisibilityProvider, { isVisible: (_uri, reader) => visible.read(reader) }); + instantiationService.stub(IRandomService, { + _serviceBrand: undefined, + generateUuid: () => `stats-${++uuid}`, + generatePrefixedUuid: namespace => `${namespace}-${++uuid}`, + }); + instantiationService.stub(IUserAttentionService, { + _serviceBrand: undefined, + isVsCodeFocused: constObservable(true), + isUserActive: constObservable(true), + hasUserAttention: constObservable(true), + totalFocusTimeMs: 0, + fireAfterGivenFocusTimePassed: () => Disposable.None, + }); + instantiationService.stub(IAiEditTelemetryService, { + _serviceBrand: undefined, + createSuggestionId: () => EditSuggestionId.newId(() => 'sgt-test'), + handleCodeAccepted: () => { }, + handleCodeRejected: () => { }, + }); + instantiationService.stub(ILogService, new NullLogService()); + + const workspace = new MutableObservableWorkspace(); + const annotatedDocuments = disposables.add(new AnnotatedDocuments(workspace, instantiationService)); + const impl = disposables.add(new EditSourceTrackingImpl(constObservable(true), annotatedDocuments, undefined, instantiationService)); + const document = disposables.add(workspace.createDocument({ + uri: URI.file('C:\\repo\\file.ts'), + initialValue: 'hello', + languageId: 'typescript', + })); + + return { disposables, document, details, headHash, branch, impl }; +} + +function chatEdit(requestId: string) { + return EditSources.chatApplyEdits({ + modelId: undefined, + sessionId: 'session-1', + requestId, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts index ea95c818a8a343..babcbe065d865b 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts @@ -62,7 +62,7 @@ suite('Edit Telemetry', () => { const w = new MutableObservableWorkspace(); const docs = disposables.add(new AnnotatedDocuments(w, instantiationService)); - disposables.add(new EditSourceTrackingImpl(constObservable(true), docs, instantiationService)); + disposables.add(new EditSourceTrackingImpl(constObservable(true), docs, undefined, instantiationService)); const d1 = disposables.add(w.createDocument({ uri: URI.parse('file:///a'), initialValue: ` diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts new file mode 100644 index 00000000000000..8ef814c7b4267d --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTracker.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; + +suite('DocumentEditSourceTracker', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('ignores an initial external edit', () => { + const document = disposables.add(new TestAnnotatedDocument('initial')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + + document.apply(StringEdit.replace(OffsetRange.ofLength(7), 'external'), EditSources.reloadFromDisk()); + + assert.deepStrictEqual(snapshot(tracker), []); + }); + + test('applies queued external edits before the next attributed edit', () => { + const document = disposables.add(new TestAnnotatedDocument('')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + const ai = chatEditSource('gpt-5', 'request-1'); + const user = EditSources.cursor({ kind: 'type' }); + + document.apply(StringEdit.insert(0, 'abcdef'), ai); + document.apply(StringEdit.delete(new OffsetRange(2, 4)), EditSources.reloadFromDisk()); + + assert.deepStrictEqual(snapshot(tracker), [{ + key: ai.toKey(1), + delta: 6, + retained: 6, + requestId: 'request-1', + }]); + + document.apply(StringEdit.insert(4, 'X'), user); + + assert.deepStrictEqual(snapshot(tracker), [ + { + key: ai.toKey(1), + delta: 6, + retained: 4, + requestId: 'request-1', + }, + { + key: 'source:cursor-kind:type', + delta: 1, + retained: 1, + requestId: undefined, + }, + { + key: 'source:reloadFromDisk', + delta: 0, + retained: 0, + requestId: undefined, + }, + ]); + }); + + test('joins level-one keys but keeps distinct model ids separate', () => { + const document = disposables.add(new TestAnnotatedDocument('')); + const tracker = disposables.add(new DocumentEditSourceTracker(document, undefined)); + const gptFirst = chatEditSource('gpt-5', 'request-1'); + const gptSecond = chatEditSource('gpt-5', 'request-2'); + const claude = chatEditSource('claude-sonnet', 'request-3'); + + document.apply(StringEdit.insert(0, 'one'), gptFirst); + document.apply(StringEdit.insert(3, 'two'), gptSecond); + document.apply(StringEdit.insert(6, 'three'), claude); + + assert.deepStrictEqual(snapshot(tracker), [ + { + key: claude.toKey(1), + delta: 5, + retained: 5, + requestId: 'request-3', + }, + { + key: gptFirst.toKey(1), + delta: 6, + retained: 6, + requestId: 'request-1', + }, + ]); + assert.strictEqual(gptFirst.toKey(1), gptSecond.toKey(1)); + }); +}); + +class TestAnnotatedDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialValue: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialValue)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} + +function chatEditSource(modelId: string, requestId: string): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId, + sessionId: 'session-1', + requestId, + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + }); +} + +function snapshot(tracker: DocumentEditSourceTracker): Array<{ key: string; delta: number; retained: number; requestId: string | undefined }> { + const retained = new Map(); + for (const range of tracker.getTrackedRanges()) { + retained.set(range.sourceKey, (retained.get(range.sourceKey) ?? 0) + range.range.length); + } + return tracker.getAllKeys().map(key => ({ + key, + delta: tracker.getTotalInsertedCharactersCount(key), + retained: retained.get(key) ?? 0, + requestId: tracker.getRepresentative(key)?.props.$$requestId, + })).sort((a, b) => a.key.localeCompare(b.key)); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts new file mode 100644 index 00000000000000..0adfeaf0796481 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentAdapters.test.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { + applyUnifiedDocumentAgentEdit, + IUnifiedDocumentModelAdapterResult, + UnifiedDocumentModelAdapter, +} from '../../browser/helpers/unifiedDocumentAdapters.js'; +import { IObservableDocument, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { UnifiedDocumentRegistry } from '../../browser/helpers/unifiedDocumentRegistry.js'; + +suite('Unified Document Adapters', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('translates model lifecycle and attributed edits', () => { + const disposables = new DisposableStore(); + const registry = createRegistry(); + const document = disposables.add(new TestObservableDocument('before')); + let dirty = false; + const results: IUnifiedDocumentModelAdapterResult[] = []; + const adapter = disposables.add(new UnifiedDocumentModelAdapter( + registry, + document, + 'before', + () => dirty, + change => change.reason, + result => results.push(result), + )); + + dirty = true; + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.cursor({ kind: 'type' }))); + adapter.dispose(); + + assert.deepStrictEqual( + { + inputs: results.map(result => result.inputKind), + outcomes: results.map(result => result.result.outcome), + model: registry.get(document.uri)?.reconciler.getSnapshot().model, + sources: registry.get(document.uri)?.reconciler.getSnapshot().transitions.map(transition => transition.source.metadata.source), + }, + { + inputs: ['connected', 'edit', 'disconnected'], + outcomes: ['applied', 'applied', 'applied'], + model: undefined, + sources: ['cursor'], + }, + ); + disposables.dispose(); + }); + + test('deduplicates an Agent Host edit followed by model reload', () => { + const disposables = new DisposableStore(); + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + const agentResult = applyUnifiedDocumentAgentEdit(registry, { + resource, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + const document = disposables.add(new TestObservableDocument('before', resource)); + const results: IUnifiedDocumentModelAdapterResult[] = []; + disposables.add(new UnifiedDocumentModelAdapter( + registry, + document, + 'before', + () => false, + change => change.reason, + result => results.push(result), + )); + + document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + + assert.deepStrictEqual( + { + agentOutcome: agentResult.transitionResult.outcome, + modelOutcomes: results.map(result => result.result.outcome), + transitions: registry.get(resource)?.reconciler.getSnapshot().transitions.map(transition => ({ + kind: transition.kind, + source: transition.source.metadata.source, + })), + }, + { + agentOutcome: 'applied', + modelOutcomes: ['applied', 'duplicate'], + transitions: [{ kind: 'agentHost', source: 'Chat.applyEdits' }], + }, + ); + disposables.dispose(); + }); + + test('transfers registry identity for Agent Host rename', () => { + const registry = createRegistry(); + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + registry.diskSnapshot(previousResource, 'content'); + const reconciler = registry.get(previousResource)?.reconciler; + + const result = applyUnifiedDocumentAgentEdit(registry, { + resource, + previousResource, + before: 'content', + after: 'content', + source: agentSource(), + correlation: 'rename-1', + kind: 'rename', + }); + + assert.deepStrictEqual( + { + transitionOutcome: result.transitionResult.outcome, + transferOutcome: result.transferResult?.outcome, + oldEntry: registry.get(previousResource), + sameReconciler: registry.get(resource)?.reconciler === reconciler, + }, + { + transitionOutcome: 'applied', + transferOutcome: 'applied', + oldEntry: undefined, + sameReconciler: true, + }, + ); + }); + + test('does not transfer a dirty rename conflict', () => { + const registry = createRegistry(); + const previousResource = URI.file('C:\\repo\\before.ts'); + const resource = URI.file('C:\\repo\\after.ts'); + registry.modelConnected(previousResource, 'content', { content: 'dirty content', dirty: true }); + + const result = applyUnifiedDocumentAgentEdit(registry, { + resource, + previousResource, + before: 'content', + after: 'content', + source: agentSource(), + correlation: 'rename-1', + kind: 'rename', + }); + + assert.deepStrictEqual( + { + transitionOutcome: result.transitionResult.outcome, + transferResult: result.transferResult, + oldEntryExists: !!registry.get(previousResource), + newEntry: registry.get(resource), + }, + { + transitionOutcome: 'skippedDirty', + transferResult: undefined, + oldEntryExists: true, + newEntry: undefined, + }, + ); + }); +}); + +class TestObservableDocument extends Disposable implements IObservableDocument { + private readonly _value: ISettableObservable; + readonly value: IObservableWithChange; + readonly version: IObservable; + readonly languageId: IObservable; + + constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + this.version = observableValue(this, 1); + this.languageId = observableValue(this, 'typescript'); + } + + apply(edit: StringEditWithReason): void { + this._value.set(edit.applyOnText(this._value.get()), undefined, edit); + } +} + +function createRegistry(): UnifiedDocumentRegistry { + return new UnifiedDocumentRegistry({ + externalSource: EditSources.reloadFromDisk(), + canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), + getComparisonKey: resource => resource.toString().toLowerCase(), + }); +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + }); +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts new file mode 100644 index 00000000000000..b97c027910b1e2 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentReconciler.test.ts @@ -0,0 +1,390 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; + +suite('UnifiedDocumentReconciler', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('deduplicates Agent Host followed by model reload', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + + test('reattributes model reload followed by Agent Host', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + const reloadResult = reconciler.modelEdit(reloadEdit('before', 'after')); + assert.deepStrictEqual( + { outcome: reloadResult.outcome, changes: reloadResult.changes, pending: reloadResult.snapshot.pendingReload?.kind }, + { outcome: 'applied', changes: [], pending: 'reloadFromDisk' }, + ); + const agentResult = reconciler.agentTransition(agentEdit('before', 'after')); + assert.deepStrictEqual( + { outcome: agentResult.outcome, changes: agentResult.changes.map(change => change.kind) }, + { outcome: 'applied', changes: ['append'] }, + ); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + + test('produces identical final state for either Agent Host and reload order', () => { + const agentFirst = createReconciler('before'); + agentFirst.modelConnected({ content: 'before', dirty: false }); + agentFirst.agentTransition(agentEdit('before', 'after')); + agentFirst.modelEdit(reloadEdit('before', 'after')); + + const reloadFirst = createReconciler('before'); + reloadFirst.modelConnected({ content: 'before', dirty: false }); + reloadFirst.modelEdit(reloadEdit('before', 'after')); + reloadFirst.agentTransition(agentEdit('before', 'after')); + + assert.deepStrictEqual(reloadFirst.getSnapshot(), agentFirst.getSnapshot()); + }); + + test('commits unmatched reload as external on disk snapshot', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit(reloadEdit('before', 'after')); + + const result = reconciler.diskSnapshot('after'); + assert.deepStrictEqual( + { outcome: result.outcome, changes: result.changes }, + { + outcome: 'applied', + changes: [{ + kind: 'append', + transition: { + id: 1, + before: 'before', + after: 'after', + source: 'reload', + kind: 'reloadFromDisk', + correlation: undefined, + agentKind: undefined, + }, + }], + }, + ); + }); + + test('skips new Agent Host attribution for a dirty model', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'user edit', dirty: true }); + + const result = reconciler.agentTransition(agentEdit('base', 'agent edit')); + assert.deepStrictEqual( + { outcome: result.outcome, snapshot: result.snapshot }, + { + outcome: 'skippedDirty', + snapshot: { + initialContent: 'base', + content: 'base', + diskContent: 'agent edit', + model: { content: 'user edit', dirty: true }, + pendingReload: undefined, + transitions: [], + }, + }, + ); + assert.strictEqual(reconciler.agentTransition(agentEdit('base', 'agent edit')).outcome, 'duplicate'); + }); + + test('claims an already observed reload even if the model became dirty later', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit(reloadEdit('before', 'after')); + reconciler.modelEdit({ + before: 'after', + after: 'after user', + source: 'user', + kind: 'model', + dirty: true, + }); + + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions.map(transition => transition.kind), ['agentHost', 'model']); + }); + + test('tracks create and delete Agent Host transitions', () => { + const reconciler = createReconciler(''); + + assert.strictEqual(reconciler.agentTransition(agentEdit('', 'created', 'create-1', 'create')).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(agentEdit('created', '', 'delete-1', 'delete')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + agentKind: transition.agentKind, + })), + [ + { before: '', after: 'created', agentKind: 'create' }, + { before: 'created', after: '', agentKind: 'delete' }, + ], + ); + }); + + test('does not deduplicate a later real transition after content cycles', () => { + const reconciler = createReconciler('a'); + reconciler.agentTransition(agentEdit('a', 'b', 'agent-1')); + reconciler.agentTransition(agentEdit('b', 'a', 'agent-2')); + + assert.strictEqual(reconciler.agentTransition(agentEdit('a', 'b', 'agent-3')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => transition.correlation), + ['agent-1', 'agent-2', 'agent-3'], + ); + }); + + test('does not claim an old external transition after disk content cycles', () => { + const reconciler = createReconciler('a'); + reconciler.diskSnapshot('b'); + reconciler.diskSnapshot('a'); + + assert.strictEqual(reconciler.agentTransition(agentEdit('a', 'b')).outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + kind: transition.kind, + })), + [ + { before: 'a', after: 'b', kind: 'diskSnapshot' }, + { before: 'b', after: 'a', kind: 'diskSnapshot' }, + { before: 'a', after: 'b', kind: 'agentHost' }, + ], + ); + }); + + test('applies disk snapshots as external edits without a model', () => { + const reconciler = createReconciler('before'); + + assert.strictEqual(reconciler.diskSnapshot('after').outcome, 'applied'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => ({ + before: transition.before, + after: transition.after, + source: transition.source, + kind: transition.kind, + })), + [{ before: 'before', after: 'after', source: 'external', kind: 'diskSnapshot' }], + ); + }); + + test('deduplicates a disk snapshot followed by model reload', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + + assert.strictEqual(reconciler.diskSnapshot('after').outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual( + reconciler.getSnapshot().transitions.map(transition => transition.kind), + ['diskSnapshot'], + ); + }); + + test('treats a model save as synchronization rather than another edit', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'user edit', + source: 'user', + kind: 'model', + dirty: true, + }); + + assert.strictEqual(reconciler.diskSnapshot('user edit').outcome, 'duplicate'); + assert.deepStrictEqual( + { + model: reconciler.getSnapshot().model, + sources: reconciler.getSnapshot().transitions.map(transition => transition.source), + }, + { model: { content: 'user edit', dirty: false }, sources: ['user'] }, + ); + assert.strictEqual(reconciler.agentTransition(agentEdit('user edit', 'agent edit')).outcome, 'applied'); + }); + + test('reports a disk conflict while the model is dirty', () => { + const reconciler = createReconciler('before'); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'user edit', + source: 'user', + kind: 'model', + dirty: true, + }); + + const result = reconciler.diskSnapshot('external edit'); + assert.deepStrictEqual( + { outcome: result.outcome, content: result.snapshot.content, diskContent: result.snapshot.diskContent }, + { outcome: 'conflict', content: 'user edit', diskContent: 'external edit' }, + ); + }); + + test('continues observing a skipped dirty model and resynchronizes on save', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'dirty one', dirty: true }); + + const modelEditResult = reconciler.modelEdit({ + before: 'dirty one', + after: 'dirty two', + source: 'user', + kind: 'model', + dirty: true, + }); + assert.deepStrictEqual( + { outcome: modelEditResult.outcome, model: modelEditResult.snapshot.model }, + { outcome: 'conflict', model: { content: 'dirty two', dirty: true } }, + ); + + const saveResult = reconciler.diskSnapshot('dirty two'); + assert.deepStrictEqual( + { + outcome: saveResult.outcome, + model: saveResult.snapshot.model, + content: saveResult.snapshot.content, + transition: saveResult.snapshot.transitions[0], + }, + { + outcome: 'applied', + model: { content: 'dirty two', dirty: false }, + content: 'dirty two', + transition: { + id: 1, + before: 'base', + after: 'dirty two', + source: 'external', + kind: 'diskSnapshot', + correlation: undefined, + agentKind: undefined, + }, + }, + ); + }); + + test('resynchronizes when a dirty save overwrites a skipped Agent Host edit', () => { + const reconciler = createReconciler('base'); + reconciler.modelConnected({ content: 'user edit', dirty: true }); + reconciler.agentTransition(agentEdit('base', 'agent edit')); + + const result = reconciler.diskSnapshot('user edit'); + assert.deepStrictEqual( + { + outcome: result.outcome, + content: result.snapshot.content, + diskContent: result.snapshot.diskContent, + model: result.snapshot.model, + transition: result.snapshot.transitions[0], + }, + { + outcome: 'applied', + content: 'user edit', + diskContent: 'user edit', + model: { content: 'user edit', dirty: false }, + transition: { + id: 1, + before: 'base', + after: 'user edit', + source: 'external', + kind: 'diskSnapshot', + correlation: undefined, + agentKind: undefined, + }, + }, + ); + }); + + test('accepts rename as a correlated no-content transition', () => { + const reconciler = createReconciler('content'); + const rename = agentEdit('content', 'content', 'rename-1', 'rename'); + + assert.strictEqual(reconciler.agentTransition(rename).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(rename).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions, []); + }); + + test('connects, disconnects, and reconnects without resetting attribution', () => { + const reconciler = createReconciler('before'); + reconciler.agentTransition(agentEdit('before', 'after')); + + assert.strictEqual(reconciler.modelConnected({ content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(reconciler.modelDisconnected().outcome, 'applied'); + assert.strictEqual(reconciler.modelConnected({ content: 'after', dirty: false }).outcome, 'applied'); + assert.deepStrictEqual(reconciler.getSnapshot().transitions, expectedAgentSnapshot('before', 'after').transitions); + }); + + test('connects a stale clean model after Agent Host and waits for reload', () => { + const reconciler = createReconciler('before'); + reconciler.agentTransition(agentEdit('before', 'after')); + + assert.strictEqual(reconciler.modelConnected({ content: 'before', dirty: false }).outcome, 'applied'); + assert.strictEqual(reconciler.modelEdit(reloadEdit('before', 'after')).outcome, 'duplicate'); + assert.deepStrictEqual(reconciler.getSnapshot(), expectedAgentSnapshot('before', 'after')); + }); + + test('reports conflicting state and correlation reuse', () => { + const reconciler = createReconciler('before'); + + assert.strictEqual(reconciler.agentTransition(agentEdit('other', 'after')).outcome, 'conflict'); + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'after')).outcome, 'applied'); + assert.strictEqual(reconciler.agentTransition(agentEdit('before', 'different')).outcome, 'conflict'); + }); +}); + +function createReconciler(initialContent: string): UnifiedDocumentReconciler { + return new UnifiedDocumentReconciler(initialContent, 'external'); +} + +function agentEdit( + before: string, + after: string, + correlation = 'agent-1', + kind: 'create' | 'edit' | 'delete' | 'rename' = 'edit', +) { + return { + before, + after, + source: 'agent', + correlation, + kind, + } as const; +} + +function reloadEdit(before: string, after: string) { + return { + before, + after, + source: 'reload', + kind: 'reloadFromDisk', + dirty: false, + } as const; +} + +function expectedAgentSnapshot(initialContent: string, content: string) { + return { + initialContent, + content, + diskContent: content, + model: { content, dirty: false }, + pendingReload: undefined, + transitions: [{ + id: 1, + before: initialContent, + after: content, + source: 'agent', + kind: 'agentHost', + correlation: 'agent-1', + agentKind: 'edit', + }], + }; +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts new file mode 100644 index 00000000000000..b03c8b920c2162 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentRegistry.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { UnifiedDocumentRegistry } from '../../browser/helpers/unifiedDocumentRegistry.js'; + +suite('UnifiedDocumentRegistry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses canonical resource identity', () => { + const registry = createRegistry(); + const first = URI.file('C:\\repo\\File.ts'); + const alias = URI.file('c:\\REPO\\file.ts'); + + registry.agentTransition(first, agentEdit('', 'content')); + + assert.strictEqual(registry.get(alias), registry.get(first)); + assert.strictEqual(registry.size, 1); + }); + + test('keeps matching remote paths on different authorities separate', () => { + const registry = createRegistry(); + const first = URI.from({ scheme: 'vscode-agent-host', authority: 'remote-one', path: '/repo/file.ts' }); + const second = URI.from({ scheme: 'vscode-agent-host', authority: 'remote-two', path: '/repo/file.ts' }); + + registry.diskSnapshot(first, 'one'); + registry.diskSnapshot(second, 'two'); + + assert.strictEqual(registry.size, 2); + assert.notStrictEqual(registry.get(first), registry.get(second)); + }); + + test('lazily creates a reconciler from an Agent Host transition', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + + const result = registry.agentTransition(resource, agentEdit('before', 'after')); + + assert.deepStrictEqual( + { + outcome: result.outcome, + resource: result.resource.toString(), + snapshot: registry.get(resource)?.reconciler.getSnapshot(), + }, + { + outcome: 'applied', + resource: URI.file('c:\\repo\\file.ts').toString(), + snapshot: { + initialContent: 'before', + content: 'after', + diskContent: 'after', + model: undefined, + pendingReload: undefined, + transitions: [{ + id: 1, + before: 'before', + after: 'after', + source: 'agent', + kind: 'agentHost', + correlation: 'agent-1', + agentKind: 'edit', + }], + }, + }, + ); + }); + + test('preserves one reconciler across model connect and disconnect', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + registry.agentTransition(resource, agentEdit('before', 'after')); + const reconciler = registry.get(resource)?.reconciler; + + assert.strictEqual(registry.modelConnected(resource, 'before', { content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(registry.modelDisconnected(resource).outcome, 'applied'); + assert.strictEqual(registry.modelConnected(resource, 'unused', { content: 'after', dirty: false }).outcome, 'applied'); + assert.strictEqual(registry.get(resource)?.reconciler, reconciler); + }); + + test('transfers reconciler identity across a rename', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\before.ts'); + const after = URI.file('C:\\repo\\after.ts'); + registry.agentTransition(before, agentEdit('content', 'content', 'rename-1', 'rename')); + const reconciler = registry.get(before)?.reconciler; + + const result = registry.transfer(before, after); + + assert.deepStrictEqual( + { + outcome: result.outcome, + oldEntry: registry.get(before), + sameReconciler: registry.get(after)?.reconciler === reconciler, + }, + { outcome: 'applied', oldEntry: undefined, sameReconciler: true }, + ); + }); + + test('rejects a rename onto an existing resource', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\before.ts'); + const after = URI.file('C:\\repo\\after.ts'); + registry.diskSnapshot(before, 'before'); + registry.diskSnapshot(after, 'after'); + + assert.strictEqual(registry.transfer(before, after).outcome, 'conflict'); + assert.strictEqual(registry.size, 2); + }); + + test('treats a canonical-only rename as a duplicate', () => { + const registry = createRegistry(); + const before = URI.file('C:\\repo\\File.ts'); + const after = URI.file('c:\\REPO\\file.ts'); + registry.diskSnapshot(before, 'content'); + + assert.strictEqual(registry.transfer(before, after).outcome, 'duplicate'); + assert.strictEqual(registry.size, 1); + assert.strictEqual(registry.get(after)?.resource.toString(), URI.file('c:\\repo\\file.ts').toString()); + }); + + test('reports model inputs for unknown resources explicitly', () => { + const registry = createRegistry(); + const resource = URI.file('C:\\repo\\file.ts'); + + assert.strictEqual(registry.modelDisconnected(resource).outcome, 'duplicate'); + assert.strictEqual(registry.modelEdit(resource, { + before: 'before', + after: 'after', + source: 'user', + kind: 'model', + dirty: true, + }).outcome, 'conflict'); + }); + + test('deletes and clears registry entries', () => { + const registry = createRegistry(); + const first = URI.file('C:\\repo\\first.ts'); + const second = URI.file('C:\\repo\\second.ts'); + registry.diskSnapshot(first, 'first'); + registry.diskSnapshot(second, 'second'); + + assert.strictEqual(registry.delete(first), true); + assert.strictEqual(registry.delete(first), false); + registry.clear(); + assert.strictEqual(registry.size, 0); + }); +}); + +function createRegistry(): UnifiedDocumentRegistry { + return new UnifiedDocumentRegistry({ + externalSource: 'external', + canonicalize: resource => resource.with({ path: resource.path.toLowerCase() }), + getComparisonKey: resource => resource.toString().toLowerCase(), + }); +} + +function agentEdit( + before: string, + after: string, + correlation = 'agent-1', + kind: 'create' | 'edit' | 'delete' | 'rename' = 'edit', +) { + return { + before, + after, + source: 'agent', + correlation, + kind, + } as const; +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts new file mode 100644 index 00000000000000..5fad5e04b28f78 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedDocumentTrackerProjection.test.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedStringEdit, StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { EditKeySourceData, EditSourceData, IDocumentWithAnnotatedEdits } from '../../browser/helpers/documentWithAnnotatedEdits.js'; +import { UnifiedDocumentReconciler } from '../../browser/helpers/unifiedDocumentReconciler.js'; +import { DocumentEditSourceTracker } from '../../browser/telemetry/editTracker.js'; +import { + IEditTrackerSnapshot, + projectUnifiedDocumentTracker, + snapshotDocumentEditSourceTracker, +} from '../../browser/telemetry/unifiedDocumentTrackerProjection.js'; + +suite('Unified Document Tracker Projection', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches an independently driven existing tracker', async () => { + const initialContent = 'a'; + const agent = agentSource(); + const user = EditSources.cursor({ kind: 'type' }); + const reconciler = new UnifiedDocumentReconciler(initialContent, EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: initialContent, dirty: false }); + reconciler.agentTransition({ + before: 'a', + after: 'ab', + source: agent, + correlation: 'tool-1', + kind: 'edit', + }); + reconciler.modelEdit({ + before: 'a', + after: 'ab', + source: EditSources.reloadFromDisk(), + kind: 'reloadFromDisk', + dirty: false, + }); + reconciler.modelEdit({ + before: 'ab', + after: 'abU', + source: user, + kind: 'model', + dirty: true, + }); + + const candidate = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + const reference = await createReferenceSnapshot(initialContent, [ + { before: 'a', after: 'ab', source: agent }, + { before: 'ab', after: 'abU', source: user }, + ]); + + assert.deepStrictEqual(candidate, reference); + }); + + test('projects reload-first attribution only after Agent Host claims it', async () => { + const reconciler = new UnifiedDocumentReconciler('before', EditSources.reloadFromDisk()); + reconciler.modelConnected({ content: 'before', dirty: false }); + reconciler.modelEdit({ + before: 'before', + after: 'after', + source: EditSources.reloadFromDisk(), + kind: 'reloadFromDisk', + dirty: false, + }); + + const pending = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + reconciler.agentTransition({ + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + const attributed = await projectUnifiedDocumentTracker(reconciler.getSnapshot(), computeDiff); + + assert.deepStrictEqual( + { + pending: { + content: pending.content, + targetContent: pending.targetContent, + hasPendingReload: pending.hasPendingReload, + sources: pending.sources, + }, + attributed: { + content: attributed.content, + targetContent: attributed.targetContent, + hasPendingReload: attributed.hasPendingReload, + sources: attributed.sources.map(source => ({ + sourceKey: source.sourceKey, + insertedCount: source.insertedCount, + retainedCount: source.retainedCount, + })), + }, + }, + { + pending: { + content: 'before', + targetContent: 'after', + hasPendingReload: true, + sources: [], + }, + attributed: { + content: 'after', + targetContent: 'after', + hasPendingReload: false, + sources: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + insertedCount: 5, + retainedCount: 5, + }], + }, + }, + ); + }); + +}); + +async function createReferenceSnapshot( + initialContent: string, + transitions: readonly { before: string; after: string; source: TextModelEditSource }[], +): Promise { + const store = new DisposableStore(); + try { + const document = store.add(new ReferenceDocument(initialContent)); + const tracker = store.add(new DocumentEditSourceTracker(document, undefined)); + let content = initialContent; + for (const transition of transitions) { + assert.strictEqual(transition.before, content); + document.apply(await computeDiff(transition.before, transition.after), transition.source); + content = transition.after; + } + await tracker.waitForQueue(); + return snapshotDocumentEditSourceTracker(tracker, content); + } finally { + store.dispose(); + } +} + +function computeDiff(before: string, after: string): Promise { + return computeStringDiff(before, after, { maxComputationTimeMs: 500 }, 'advanced'); +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + }); +} + +class ReferenceDocument extends Disposable implements IDocumentWithAnnotatedEdits { + private readonly _value: ISettableObservable }>; + readonly value: IObservableWithChange }>; + + constructor(initialContent: string) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + } + + apply(edit: StringEdit, source: TextModelEditSource): void { + const data = new EditSourceData(source).toEditSourceData(); + this._value.set(edit.applyOnText(this._value.get()), undefined, { edit: edit.mapData(() => data) }); + } + + waitForQueue(): Promise { + return Promise.resolve(); + } +} diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts new file mode 100644 index 00000000000000..75e160dcec91a9 --- /dev/null +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/unifiedEditSourceTracking.test.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, IObservableWithChange, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { StringText } from '../../../../../editor/common/core/text/abstractText.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { computeStringDiff } from '../../../../../editor/common/services/editorWebWorker.js'; +import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from '../../browser/helpers/observableWorkspace.js'; +import { IRandomService } from '../../browser/randomService.js'; +import { IEditSourcesDetailsTelemetryData } from '../../browser/telemetry/editSourceTelemetry.js'; +import { UnifiedEditSourceTracking } from '../../browser/telemetry/unifiedEditSourceTracking.js'; + +suite('Unified Edit Source Tracking', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('emits mixed local and Agent Host details once per long-term window', async () => { + const context = createContext('base'); + const resource = context.document.uri; + context.tracking.applyAgentEdit({ + resource, + before: 'base', + after: 'baseA', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.document.apply(StringEditWithReason.replace(new OffsetRange(4, 4), 'A', EditSources.reloadFromDisk())); + context.setDirty(true); + context.document.apply(StringEditWithReason.replace(new OffsetRange(5, 5), 'U', EditSources.cursor({ kind: 'type' }))); + + const first = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + const second = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-2'); + + assert.deepStrictEqual({ + first, + second, + events: context.details.map(event => ({ + sourceKey: event.sourceKey, + trigger: event.trigger, + languageId: event.languageId, + statsUuid: event.statsUuid, + origin: event.origin, + harness: event.harness, + modifiedCount: event.modifiedCount, + deltaModifiedCount: event.deltaModifiedCount, + totalModifiedCount: event.totalModifiedCount, + })), + }, { + first: { + totalModifiedCount: 2, + rows: [ + { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + cleanedSourceKey: 'source:Chat.applyEdits-$harness:copilotcli-$origin:agentHost', + extensionId: undefined, + extensionVersion: undefined, + modelId: 'model', + conversationId: 'session', + requestId: 'request', + origin: 'agentHost', + harness: 'copilotcli', + modifiedCount: 1, + deltaModifiedCount: 1, + }, + { + sourceKey: 'source:cursor-kind:type', + cleanedSourceKey: 'source:cursor-kind:type', + extensionId: undefined, + extensionVersion: undefined, + modelId: undefined, + conversationId: undefined, + requestId: undefined, + origin: undefined, + harness: undefined, + modifiedCount: 1, + deltaModifiedCount: 1, + }, + ], + }, + second: undefined, + events: [ + { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + origin: 'agentHost', + harness: 'copilotcli', + modifiedCount: 1, + deltaModifiedCount: 1, + totalModifiedCount: 2, + }, + { + sourceKey: 'source:cursor-kind:type', + trigger: 'hashChange', + languageId: 'typescript', + statsUuid: 'stats-1', + origin: undefined, + harness: undefined, + modifiedCount: 1, + deltaModifiedCount: 1, + totalModifiedCount: 2, + }, + ], + }); + context.disposables.dispose(); + }); + + test('reconciles the current disk snapshot before emitting retained counts', async () => { + const context = createContext('base'); + const resource = context.document.uri; + context.tracking.applyAgentEdit({ + resource, + before: 'base', + after: 'baseABCDEFGHIJ', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.document.apply(StringEditWithReason.replace(new OffsetRange(4, 4), 'ABCDEFGHIJ', EditSources.reloadFromDisk())); + context.setDiskContent('baseABC'); + + await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + + const agentEvent = context.details.find(event => event.origin === 'agentHost'); + assert.deepStrictEqual(agentEvent && { + sourceKey: agentEvent.sourceKey, + modifiedCount: agentEvent.modifiedCount, + deltaModifiedCount: agentEvent.deltaModifiedCount, + totalModifiedCount: agentEvent.totalModifiedCount, + }, { + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + modifiedCount: 7, + deltaModifiedCount: 14, + totalModifiedCount: 7, + }); + context.disposables.dispose(); + }); + + test('waits for a pending reload to be attributed before flushing', async () => { + const context = createContext('before'); + const resource = context.document.uri; + context.document.apply(StringEditWithReason.replace(OffsetRange.ofLength(6), 'after', EditSources.reloadFromDisk())); + + const pending = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript'); + context.tracking.applyAgentEdit({ + resource, + before: 'before', + after: 'after', + source: agentSource(), + correlation: 'tool-1', + kind: 'edit', + }); + context.setDiskContent('after'); + const attributed = await context.tracking.flushLongTermDetails(resource, 'hashChange', 'typescript', 'stats-1'); + + assert.deepStrictEqual({ + pending, + attributed: attributed?.rows.map(row => ({ + sourceKey: row.sourceKey, + modifiedCount: row.modifiedCount, + })), + eventCount: context.details.length, + }, { + pending: undefined, + attributed: [{ + sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', + modifiedCount: 5, + }], + eventCount: 1, + }); + context.disposables.dispose(); + }); + + test('emits only the top thirty retained sources in descending order', async () => { + const context = createContext(''); + context.setDirty(true); + for (let i = 1; i <= 31; i++) { + const content = 'x'.repeat(i); + context.document.apply(StringEditWithReason.replace( + OffsetRange.emptyAt(context.document.value.get().value.length), + content, + EditSources.unknown({ name: `source-${i}` }), + )); + } + + await context.tracking.flushLongTermDetails(context.document.uri, 'hashChange', 'typescript'); + + assert.deepStrictEqual({ + count: context.details.length, + first: context.details[0]?.sourceKey, + last: context.details.at(-1)?.sourceKey, + containsSmallest: context.details.some(event => event.sourceKey === 'source:unknown-name:source-1'), + }, { + count: 30, + first: 'source:unknown-name:source-31', + last: 'source:unknown-name:source-2', + containsSmallest: false, + }); + context.disposables.dispose(); + }); + + test('keeps a resource until local and Agent Host ownership end', async () => { + const context = createContext('content'); + const resource = context.document.uri; + context.tracking.retainLocalLongTermResource(resource); + context.tracking.retainAgentResource(resource); + context.workspace.setDocuments([]); + await Promise.resolve(); + const whileRetained = !!context.tracking.getSnapshot(resource); + + context.tracking.releaseLocalLongTermResource(resource); + context.tracking.releaseAgentResource(resource); + await Promise.resolve(); + + assert.deepStrictEqual({ + whileRetained, + afterRelease: context.tracking.getSnapshot(resource), + }, { + whileRetained: true, + afterRelease: undefined, + }); + context.disposables.dispose(); + }); +}); + +function createContext(initialContent: string) { + const disposables = new DisposableStore(); + const workspace = new TestWorkspace(); + const document = disposables.add(new TestObservableDocument(initialContent)); + workspace.setDocuments([document]); + const details: IEditSourcesDetailsTelemetryData[] = []; + let dirty = false; + let diskContent = initialContent; + let uuid = 0; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection(), false, undefined, true)); + instantiationService.stub(IFileService, { + hasProvider: () => true, + readFile: async resource => { + const value = VSBuffer.fromString(diskContent); + return { + resource, + name: 'file.ts', + size: value.byteLength, + mtime: 0, + ctime: 0, + etag: '', + readonly: false, + locked: false, + executable: false, + value, + }; + }, + }); + instantiationService.stub(ITextFileService, { isDirty: () => dirty }); + instantiationService.stub(IUriIdentityService, { extUri, asCanonicalUri: resource => resource }); + instantiationService.stub(IEditorWorkerService, { + computeStringEditFromDiff: (original, modified) => computeStringDiff(original, modified, { maxComputationTimeMs: 500 }, 'advanced'), + }); + instantiationService.stub(IRandomService, { + _serviceBrand: undefined, + generateUuid: () => `stats-${++uuid}`, + generatePrefixedUuid: namespace => `${namespace}-${++uuid}`, + }); + instantiationService.stub(ITelemetryService, { + publicLog2(eventName, data) { + if (eventName === 'editTelemetry.editSources.details') { + details.push(data as IEditSourcesDetailsTelemetryData); + } + }, + }); + instantiationService.stub(ILogService, new NullLogService()); + const tracking = disposables.add(instantiationService.createInstance(UnifiedEditSourceTracking, workspace)); + return { + disposables, + workspace, + document, + tracking, + details, + setDirty(value: boolean) { + dirty = value; + }, + setDiskContent(value: string) { + diskContent = value; + }, + }; +} + +function agentSource(): TextModelEditSource { + return EditSources.chatApplyEdits({ + modelId: 'model', + sessionId: 'session', + requestId: 'request', + languageId: 'typescript', + mode: 'agent', + extensionId: undefined, + codeBlockSuggestionId: undefined, + harness: 'copilotcli', + origin: 'agentHost', + }); +} + +class TestWorkspace extends ObservableWorkspace { + private readonly _documents = observableValue(this, []); + override readonly documents: IObservable = this._documents; + + setDocuments(documents: readonly IObservableDocument[]): void { + this._documents.set(documents, undefined); + } +} + +class TestObservableDocument extends Disposable implements IObservableDocument { + private readonly _value: ISettableObservable; + readonly value: IObservableWithChange; + readonly version: IObservable; + readonly languageId: IObservable; + + constructor(initialContent: string, readonly uri = URI.file('C:\\repo\\file.ts')) { + super(); + this.value = this._value = observableValue(this, new StringText(initialContent)); + this.version = observableValue(this, 1); + this.languageId = observableValue(this, 'typescript'); + } + + apply(edit: StringEditWithReason): void { + this._value.set(edit.applyOnText(this._value.get()), undefined, edit); + } +}