From a19309aecd45171964518d2d60fdd9eae66ba420 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Fri, 21 Aug 2026 00:03:40 -0700 Subject: [PATCH 1/9] agentHost: Temporarily disable GPT tool search (#331902) agentHost: temporarily disable GPT tool search --- .../agentHost/node/copilot/toolSearchDeferral.ts | 13 ++++++++----- .../agentHost/test/node/toolSearchDeferral.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index d9426746d6b21f..21cb4023a8f193 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isGpt56Model } from './modelIdentifiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -19,16 +18,20 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set { } }); - test('supports OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { + test('temporarily rejects OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { for (const id of ['gpt-5.4', 'gpt-5.5', 'gpt-5-4', 'gpt-5-5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { - assert.strictEqual(agentHostModelSupportsToolSearch(id), true, id); + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); } }); From 6b335613aeba4f5138819b30cf87fc7cb5341dd8 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:19:32 +0200 Subject: [PATCH 2/9] Agents - clean-up worktree creation code (#331910) * Agents - clean-up worktree creation code * Fix integration test --- .../agentHost/common/agentHostGitService.ts | 13 +++- .../agentHost/node/agentHostGitService.ts | 28 +++++---- .../node/shared/worktreeIsolation.ts | 9 ++- .../agentHostGitService.integrationTest.ts | 61 +++++++++++++++++-- .../agentHost/test/node/copilotAgent.test.ts | 12 ++-- .../node/shared/worktreeIsolation.test.ts | 50 +++++++-------- 6 files changed, 124 insertions(+), 49 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index be5cfadaa082b6..4b865ed6fc6541 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -203,6 +203,15 @@ export interface IWorktreeFileProgress { readonly filesTotal: number; } +export interface IAddWorktreeOptions { + readonly path: URI; + readonly commitish: string; + readonly newBranchName?: string; + readonly track: boolean; + readonly preferRemoteBranch?: boolean; + readonly onProgress?: (progress: IWorktreeFileProgress) => void; +} + export interface IAgentHostGitService { readonly _serviceBrand: undefined; getCurrentBranch(workingDirectory: URI): Promise; @@ -215,13 +224,13 @@ export interface IAgentHostGitService { /** Returns worktree roots in Git's porcelain order, with the primary worktree first. */ getWorktreeRoots(workingDirectory: URI): Promise; /** - * Creates a worktree for a new branch. `onProgress` receives every checkout + * Creates a worktree, optionally on a new branch. `onProgress` receives every checkout * sample git reports, which can be several per second, so consumers are * expected to round and rate limit for their own presentation. It may also * never be called (fast checkouts and git versions that stay silent), so it * MUST be treated as best-effort. */ - addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string, track: boolean, onProgress?: (progress: IWorktreeFileProgress) => void): Promise; + addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise; /** * Copies the git-ignored files matching `globs` into the worktree. * `onProgress` counts the individual files covered, but only fires as whole diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 1772836f753aa2..f7576e8d416e33 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; -import { EMPTY_TREE_OBJECT, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { firstParallel, Limiter, SequencerByKey, timeout } from '../../../base/common/async.js'; @@ -151,26 +151,32 @@ export class AgentHostGitService implements IAgentHostGitService { .map(line => URI.file(line.substring('worktree '.length))); } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string, track = false, onProgress?: (progress: IWorktreeFileProgress) => void): Promise { - const resolvedStartPoint = await this._resolveRemoteTrackingBranch(repositoryRoot, startPoint, track) ?? startPoint; + async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { + const resolvedCommitish = options.preferRemoteBranch + ? await this._resolveRemoteTrackingBranch(repositoryRoot, options.commitish, options.track) ?? options.commitish + : options.commitish; const args = ['-c', 'checkout.workers=0', 'worktree', 'add']; - if (!track) { - // Pass --no-track so the new agent branch never picks up upstream - // tracking from the start point (e.g. when starting from - // 'origin/main', without --no-track git would set the new branch's - // upstream to origin/main, which would mis-attribute pushes/pulls). - args.push('--no-track'); + if (options.newBranchName) { + if (!options.track) { + // Pass --no-track so the new agent branch never picks up upstream + // tracking from the start point (e.g. when starting from + // 'origin/main', without --no-track git would set the new branch's + // upstream to origin/main, which would mis-attribute pushes/pulls). + args.push('--no-track'); + } + + args.push('-b', options.newBranchName); } - args.push('-b', branchName, worktree.fsPath, resolvedStartPoint); + args.push(options.path.fsPath, resolvedCommitish); // `git worktree add` forces progress reporting on its internal checkout // even when stderr is a pipe, so `Updating files: N% (x/y)` can be // parsed for live feedback. GIT_PROGRESS_DELAY=0 lifts git's default // two-second suppression so the first sample arrives immediately. - const progressParser = onProgress ? new GitCheckoutProgressParser(onProgress) : undefined; + const progressParser = options.onProgress ? new GitCheckoutProgressParser(options.onProgress) : undefined; await this._runGit(repositoryRoot, args, { timeout: 180_000, diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 8695d93b44a0e7..414bed17323544 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -625,7 +625,14 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => - this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch, worktreeBranchTrack, progress)); + this._gitService.addWorktree(repositoryRoot, { + path: worktree, + commitish: baseBranch, + newBranchName: branchName, + track: worktreeBranchTrack, + preferRemoteBranch: true, + onProgress: progress, + })); return { branchName, worktree, baseBranch }; }); const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 1a1f5c8bd568db..a74bd772cfd09f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -746,12 +746,35 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { } }); + (hasGit ? test : test.skip)('addWorktree attaches a worktree without creating a new branch', async () => { + const dir = initRepo(); + cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + track: false, + }); + + assert.strictEqual(cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), 'feature'); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + (hasGit ? test : test.skip)('removeWorktree preserves dirty work unless forced', async () => { const dir = initRepo(); const fs = await import('fs/promises'); const wtPath = join(dir, '..', `wt-dirty-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/dirty-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/dirty-worktree', + track: false, + }); await fs.writeFile(join(wtPath, 'untracked.txt'), 'keep me'); let safeRemovalFailed = false; @@ -785,7 +808,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const suffix = `wt-prune-${Date.now()}`; const wtPath = join(dir, '..', suffix); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/prune-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/prune-worktree', + track: false, + }); // Reproduce the CI teardown race: the working tree directory is gone // but git still holds the `.git/worktrees/` admin entry, so a plain // `git worktree remove` fails — removeWorktree must fall back to prune. @@ -815,7 +843,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', suffix); let worktreeLocked = false; try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/leak-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/leak-worktree', + track: false, + }); cp.execFileSync('git', ['worktree', 'lock', wtPath], { cwd: dir, env, stdio: 'pipe' }); worktreeLocked = true; // A locked missing worktree makes prune exit 0 while retaining the admin entry on every OS. @@ -846,7 +879,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const suffix = `wt-orphan-${Date.now()}`; const wtPath = join(dir, '..', suffix); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/orphan-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/orphan-worktree', + track: false, + }); // De-register the worktree (delete git's admin entries) while leaving the working-tree directory in place. const adminRoot = join(dir, '.git', 'worktrees'); for (const entry of readdirSync(adminRoot)) { @@ -896,7 +934,13 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', `wt-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/test-origin-start-point', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/test-origin-start-point', + preferRemoteBranch: true, + track: false, + }); const stat = await fs.stat(join(wtPath, 'upstream.txt')); assert.ok(stat.isFile(), 'worktree should start from origin/main, not stale local main'); assert.throws(() => cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: wtPath, env, stdio: 'pipe' }), /fatal:/); @@ -941,7 +985,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', `wt-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/include-files', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/include-files', + track: false, + }); const progress: { filesDone: number; filesTotal: number }[] = []; await svc!.copyWorktreeIncludeFiles(URI.file(dir), URI.file(wtPath), ['.env', 'secrets/**', 'partial/*.txt', 'app/**'], sample => progress.push(sample)); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 688f6882ceb241..631d9fdd728b59 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -53,7 +53,7 @@ import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } fro import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPromptCache, IAgentHostPromptCache } from '../../node/agentHostPromptCache.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../node/agentHostSessionTitleSignal.js'; -import { IAgentHostGitService, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; +import { IAgentHostGitService, type IAddWorktreeOptions, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; @@ -263,7 +263,7 @@ class TestAgentHostGitService implements IAgentHostGitService { repositoryRoot: URI | undefined = undefined; headCommit: string | undefined = '0'.repeat(40); - addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; + addedWorktrees: { repositoryRoot: URI; options: IAddWorktreeOptions }[] = []; addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; existingBranches = new Set(); @@ -276,9 +276,11 @@ class TestAgentHostGitService implements IAgentHostGitService { async getBranches(): Promise { return []; } async getRepositoryRoot(): Promise { return this.repositoryRoot; } async getWorktreeRoots(): Promise { return []; } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); - this.existingBranches.add(branchName); + async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { + this.addedWorktrees.push({ repositoryRoot, options }); + if (options.newBranchName) { + this.existingBranches.add(options.newBranchName); + } } async copyWorktreeIncludeFiles(): Promise { } async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 24497ef0e8cb9e..f4e59d4424915d 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -13,7 +13,7 @@ import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; -import { GitRefType, IAgentHostGitService } from '../../../common/agentHostGitService.js'; +import { GitRefType, IAgentHostGitService, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; @@ -45,7 +45,7 @@ suite('WorktreeIsolation', () => { let repoRoot: URI; let worktreesRoot: URI; let db: TestSessionDatabase; - let addWorktreeCalls: { worktree: URI; branchName: string; startPoint: string; track: boolean }[]; + let addWorktreeCalls: IAddWorktreeOptions[]; let addExistingCalls: { worktree: URI; branchName: string }[]; let removeCalls: { worktree: URI; force: boolean }[]; let copyIncludeCalls: { repositoryRoot: URI; worktree: URI; globs: readonly string[] }[]; @@ -71,9 +71,9 @@ suite('WorktreeIsolation', () => { ], branchExists: async () => branchExists, hasUncommittedChanges: async () => hasUncommittedChanges, - addWorktree: async (_root, worktree, branch, startPoint, track) => { - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktree: async (_root, options) => { + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); }, copyWorktreeIncludeFiles: async (repositoryRoot, worktree, globs) => { copyIncludeCalls.push({ repositoryRoot, worktree, globs: [...globs] }); @@ -197,7 +197,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ branchDefault: config.branchDefault, branchEnum: config.branchProperty?.protocol.enum, - startPoint: addWorktreeCalls[0]?.startPoint, + startPoint: addWorktreeCalls[0]?.commitish, }, { branchDefault: 'main', branchEnum: ['main'], @@ -218,7 +218,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ returnedWorktree: first!.toString(), addWorktreeCallCount: addWorktreeCalls.length, - addWorktreeArgs: addWorktreeCalls.map(c => ({ worktree: c.worktree.toString(), branchName: c.branchName, startPoint: c.startPoint })), + addWorktreeArgs: addWorktreeCalls.map(c => ({ worktree: c.path.toString(), branchName: c.newBranchName, startPoint: c.commitish })), metaBranch: meta?.branchName, metaWorktree: meta?.worktreePath?.toString(), metaRepo: meta?.repositoryRoot?.toString(), @@ -246,10 +246,10 @@ suite('WorktreeIsolation', () => { let addWorktreeRoot: URI | undefined; gitService.getRepositoryRoot = async () => checkoutRoot; gitService.getWorktreeRoots = async () => [repoRoot, checkoutRoot]; - gitService.addWorktree = async (repositoryRoot, worktree, branch, startPoint, track) => { + gitService.addWorktree = async (repositoryRoot, options) => { addWorktreeRoot = repositoryRoot; - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); }; const isolation = createIsolation(disposables, { gitService }); const includeFiles = ['.env']; @@ -309,14 +309,14 @@ suite('WorktreeIsolation', () => { test('resolveWorkingDirectory names each creation phase, rounding percentages down and debouncing updates', async () => { const gitService = createGitService(); - gitService.addWorktree = async (_root, worktree, branch, startPoint, track, onProgress) => { - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); - onProgress?.({ filesDone: 7, filesTotal: 800 }); - onProgress?.({ filesDone: 96, filesTotal: 800 }); - onProgress?.({ filesDone: 100, filesTotal: 800 }); + gitService.addWorktree = async (_root, options) => { + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); + options.onProgress?.({ filesDone: 7, filesTotal: 800 }); + options.onProgress?.({ filesDone: 96, filesTotal: 800 }); + options.onProgress?.({ filesDone: 100, filesTotal: 800 }); await timeout(50); - onProgress?.({ filesDone: 800, filesTotal: 800 }); + options.onProgress?.({ filesDone: 800, filesTotal: 800 }); }; gitService.copyWorktreeIncludeFiles = async (_root, _worktree, _globs, onProgress) => { onProgress?.({ filesDone: 1, filesTotal: 4 }); @@ -368,7 +368,7 @@ suite('WorktreeIsolation', () => { }); assert.deepStrictEqual({ - branchName: addWorktreeCalls[0]?.branchName, + branchName: addWorktreeCalls[0]?.newBranchName, worktree: resolved?.toString(), }, { branchName: 'agents/add-feature-12345678', @@ -402,7 +402,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ branchExistsCalls, - branchName: addWorktreeCalls[0]?.branchName, + branchName: addWorktreeCalls[0]?.newBranchName, worktree: resolved?.toString(), }, { branchExistsCalls: 2, @@ -421,13 +421,15 @@ suite('WorktreeIsolation', () => { gitService.getRepositoryRoot = async workingDirectory => workingDirectory; gitService.getWorktreeRoots = async () => [repoRoot, checkoutRootA, checkoutRootB]; gitService.branchExists = async (_repositoryRoot, candidate) => existingBranches.has(candidate); - gitService.addWorktree = async (_repositoryRoot, worktree, candidate, startPoint, track) => { + gitService.addWorktree = async (_repositoryRoot, options) => { activeAddWorktrees++; maxActiveAddWorktrees = Math.max(maxActiveAddWorktrees, activeAddWorktrees); await timeout(10); - addWorktreeCalls.push({ worktree, branchName: candidate, startPoint, track }); - existingBranches.add(candidate); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktreeCalls.push(options); + if (options.newBranchName) { + existingBranches.add(options.newBranchName); + } + mkdirSync(options.path.fsPath, { recursive: true }); activeAddWorktrees--; }; const isolation = createIsolation(disposables, { @@ -443,7 +445,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ maxActiveAddWorktrees, - branchNames: addWorktreeCalls.map(call => call.branchName), + branchNames: addWorktreeCalls.map(call => call.newBranchName), worktrees: worktrees.map(worktree => worktree?.toString()), }, { maxActiveAddWorktrees: 1, From 2fdb53f764d605df1392541aeee57cba89f7982f Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:55:52 +0200 Subject: [PATCH 3/9] Add end-to-end trace logging for opening a session (#331890) Opening a session spans the sessions list, the chat model load, an AHP round trip and the agent host's database reads before anything renders, but the timeline had large unlogged gaps. Add trace markers along that whole path - for both the Agents window list and the workbench chat sessions list - carrying the session resource so a single open can be followed through the log. Markers were only added where none existed; the already-instrumented ChatWidgetService.openSession and ChatViewPane.loadSession are left alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 2 ++ .../platform/agentHost/node/agentService.ts | 16 +++++++++++++++- .../node/copilot/copilotAgentSession.ts | 3 +++ .../sessions/contrib/chat/browser/chatView.ts | 8 +++++++- .../sessions/browser/sessionsService.ts | 1 + .../agentHost/agentHostSessionHandler.ts | 6 ++++++ .../agentSessions/agentSessionsOpener.ts | 6 ++++++ .../chatSessions/chatSessions.contribution.ts | 3 +++ .../contrib/chat/browser/widget/chatWidget.ts | 19 ++++++++++++++++++- .../common/chatService/chatServiceImpl.ts | 4 ++++ 10 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index acfa3007018d05..115f6498c9df8a 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -969,10 +969,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * response. */ async subscribe(resource: URI): Promise { + this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${resource.toString()}`); const result = await this._sendRequest('subscribe', { channel: resource.toString() }); if (!result.snapshot) { throw new Error(`subscribe to ${resource.toString()} returned no snapshot`); } + this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${resource.toString()}`); return result.snapshot; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6a7cee7d46f4a1..241ae294e7ba0b 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3066,7 +3066,10 @@ export class AgentService extends Disposable implements IAgentService { */ private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin): Promise { const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; - const turns = await this._applyPersistedTurnUsage(chat, await provider.chats.getMessages(chat, context)); + this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); + const providerTurns = await provider.chats.getMessages(chat, context); + this._logService.trace(`[AgentService] getChatMessages: provider returned ${providerTurns.length} turn(s) for chat=${chat.toString()}`); + const turns = await this._applyPersistedTurnUsage(chat, providerTurns); // Host-owned worktree restore announcement: re-inject the "Created isolated // worktree" message at the top of the default chat's first turn from // persisted metadata. No-op for folder sessions and non-default chats (peer @@ -3111,6 +3114,7 @@ export class AgentService extends Disposable implements IAgentService { } try { usages = await ref.object.getTurnUsages(); + this._logService.trace(`[AgentService] getTurnUsages done: ${usages.size} row(s) for ${storage.toString()}`); } catch (err) { this._logService.warn(`[AgentService] Failed to read persisted turn usage for ${storage.toString()}`, err); return turns; @@ -3790,6 +3794,7 @@ export class AgentService extends Disposable implements IAgentService { } let snapshot = this._stateManager.getSnapshot(resourceStr); + const servedFromMemory = !!snapshot; const parsedChangeset = parseChangesetUri(resourceStr); if (snapshot && parsedChangeset && !this._stateManager.getSessionState(parsedChangeset.sessionUri)) { await this._changesetCoordinator.restoreSessionIfChangesetSubscription(resource, s => this.restoreSession(s)); @@ -3876,6 +3881,7 @@ export class AgentService extends Disposable implements IAgentService { void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); } + this._logService.trace(`[AgentService] subscribe done: ${resourceStr} (servedFromMemory=${servedFromMemory})`); return snapshot; } catch (err) { this.unsubscribe(resource, clientId); @@ -4689,6 +4695,7 @@ export class AgentService extends Disposable implements IAgentService { const inFlight = this._restoreSessionInFlight.get(sessionStr); if (inFlight) { + this._logService.trace(`[AgentService] restoreSession: joining in-flight restore for ${sessionStr}`); return inFlight; } @@ -4696,10 +4703,12 @@ export class AgentService extends Disposable implements IAgentService { return; } + this._logService.trace(`[AgentService] restoreSession start: ${sessionStr}`); const restore = this._doRestoreSession(session, sessionStr); this._restoreSessionInFlight.set(sessionStr, restore); try { await restore; + this._logService.trace(`[AgentService] restoreSession done: ${sessionStr}`); } finally { if (this._restoreSessionInFlight.get(sessionStr) === restore) { this._restoreSessionInFlight.delete(sessionStr); @@ -4762,6 +4771,7 @@ export class AgentService extends Disposable implements IAgentService { } const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); const external = registeredSession?.external ?? false; + this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`); // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session, strictly gated on the live migrate setting (a no-op for native / already-adopted sessions). const migrateLegacyEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; @@ -4891,6 +4901,7 @@ export class AgentService extends Disposable implements IAgentService { * fails so the caller can report the outcome accurately. */ private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], catalogReadable: boolean, sessionKnownToRegistry: boolean): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`); let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { // Authoritative absence only when the catalog was readable this run and @@ -4901,6 +4912,7 @@ export class AgentService extends Disposable implements IAgentService { ? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`) : new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`); } + this._logService.trace(`[AgentService] restore: provider metadata resolved for ${sessionStr}`); // A freshly-adopted legacy session whose working directory is a // pre-existing git worktree keeps no worktree metadata (adoption seeds @@ -5079,6 +5091,7 @@ export class AgentService extends Disposable implements IAgentService { // Best-effort: fall back to agent-provided metadata } } + this._logService.trace(`[AgentService] restore: persisted session metadata read for ${sessionStr}`); // Encode isRead/isArchived as status bitmask flags let status: SessionStatus = SessionStatus.Idle; @@ -5127,6 +5140,7 @@ export class AgentService extends Disposable implements IAgentService { } this._invalidateSessionList(); this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); + this._logService.trace(`[AgentService] restore: hydrated state for ${sessionStr} with ${mergedTurns.length} turn(s)`); this._serverToolHost.advertise(sessionStr); // A freshly-adopted legacy session bridges its git checkpoints into the diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index fef93853ca12e3..775c30dc2747d3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2655,7 +2655,9 @@ export class CopilotAgentSession extends Disposable { } private async _computeMappedEvents(): Promise { + this._logService.trace(`[Copilot:${this.sessionId}] Reading persisted session events`); const events = await this._wrapper.session.getEvents(); + this._logService.trace(`[Copilot:${this.sessionId}] Read ${events.length} persisted event(s); reconstructing turns`); let db: ISessionDatabase | undefined; try { db = this._databaseRef.object; @@ -2668,6 +2670,7 @@ export class CopilotAgentSession extends Disposable { ? this._launchPlan.model : this._launchPlan.fallback.model, }); + this._logService.trace(`[Copilot:${this.sessionId}] Reconstructed ${result.turns.length} turn(s) from ${events.length} event(s)`); return result; } diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index b90338250a52d9..fef3a3bc238e8e 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -382,6 +382,7 @@ export class ChatView extends AbstractChatView { this._currentChatResource = resource; this._currentChatResourceObs.set(resource, undefined); + this.logService.trace(`[ChatView] setChat start uri=${resource.toString()} session=${session?.resource.toString()}`); // Cancel any in-flight load for the previous chat and start a fresh one. this._loadCts.value?.cancel(); @@ -403,8 +404,10 @@ export class ChatView extends AbstractChatView { if (isEqual(this._currentChatResource, resource)) { this._widget.setLoading(false); } + this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); return; } + this.logService.trace(`[ChatView] setChat model loaded uri=${resource.toString()}`); this._modelRef.value = ref; this._updateWidgetLockState(getChatSessionType(ref.object.sessionResource)); setModelPreservingInputTypedWhileLoading(this._widget, inputBeforeLoad, () => this._widget.setModel(ref.object)); @@ -418,9 +421,12 @@ export class ChatView extends AbstractChatView { // Set AFTER `setModel` so observers see the attribute only once the // inner widget is fully attached to the loaded model. this.element.dataset.boundChatResource = resource.toString(); + this.logService.trace(`[ChatView] setChat done uri=${resource.toString()}`); }, err => { if (!token.isCancellationRequested) { - this.logService.error('[ChatView] Failed to load chat model for chat', err); + this.logService.error(`[ChatView] Failed to load chat model for chat uri=${resource.toString()}`, err); + } else { + this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); } if (isEqual(this._currentChatResource, resource)) { // might have changed while we were waiting, only reset if it is still the same this._currentChatResource = undefined; diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 90d9bec4cc70c3..4e19d759fe9f15 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -786,6 +786,7 @@ export class SessionsService extends Disposable implements ISessionsService { } async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise { + this.logService.trace(`[SessionsView] openSession requested uri=${sessionResource.toString()}`); // Claim the open before resolving: resolution can take seconds for a legacy // Copilot CLI resource, and a newer open must win regardless of which // resolution finishes first. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ab039beacfff6a..1ec57fa4094958 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1311,6 +1311,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // whether this session resource represents a new session that hasn't yet // been created on the backend. const isNewSession = this._isNewSessionResource(sessionResource); + this._logService.trace(`[AgentHost] provideChatSessionContent start: ${resolvedSession.toString()} (isNewSession=${isNewSession})`); const history: IChatSessionHistoryItem[] = []; let initialProgress: IChatProgress[] | undefined; let initialResponsePartCount = 0; @@ -1346,6 +1347,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sub.value instanceof Error) { throw sub.value; } + this._logService.trace(`[AgentHost] provideChatSessionContent: session state hydrated for ${resolvedSession.toString()}`); const rawState = this._getRawSessionState(resolvedSession.toString()); if (!rawState) { throw new Error(`Session state did not hydrate for ${resolvedSession.toString()}`); @@ -1355,6 +1357,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const chatSub = this._ensureChatSubscription(resolvedSession.toString(), chatURI); chatSubscription = chatSub; await this._whenSubscriptionHydrated(chatSub, token); + this._logService.trace(`[AgentHost] provideChatSessionContent: chat state hydrated for ${chatURI}`); const sessionState = this._getSessionState(resolvedSession.toString(), chatURI); if (sessionState) { sessionTitle = sessionState.title; @@ -1374,11 +1377,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._chatErrorContext(), this._config.connection.initializeResult.get()?.terminalCommandPrefix, )); + this._logService.trace(`[AgentHost] provideChatSessionContent: converted ${sessionState.turns.length} turn(s) into ${history.length} history item(s) for ${resolvedSession.toString()}`); // Enrich history with inner tool calls from subagent // child sessions. Subscribes to each child session so // its tool calls appear grouped under the parent widget. await this._enrichHistoryWithSubagentCalls(history, resolvedSession, sessionResource, sessionState, historySubagentObservations); + this._logService.trace(`[AgentHost] provideChatSessionContent: subagent enrichment done for ${resolvedSession.toString()}`); // Store historical turns so the editing session can seed a // request-level checkpoint for each turn (with file edits @@ -1589,6 +1594,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + this._logService.trace(`[AgentHost] provideChatSessionContent done: ${resolvedSession.toString()} with ${history.length} history item(s)`); return session; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index c2de8ec1d05f3d..f4369e66c2795f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -99,6 +99,8 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes const instantiationService = accessor.get(IInstantiationService); const logService = accessor.get(ILogService); + logService.trace(`[AgentSessions] openSession start: ${session.resource.toString()}`); + // List and picker clicks arrive here with a resolved session, so the redirect // has to happen on this path too or those opens never migrate. A no-op for // anything that is not a superseded legacy resource. @@ -119,6 +121,7 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes try { const handled = await instantiationService.invokeFunction(accessor => participant.handleOpenSession(accessor, session, openOptions)); if (handled) { + logService.trace(`[AgentSessions] openSession handled by participant: ${session.resource.toString()}`); return undefined; // Participant handled the session, skip default opening } } catch (error) { @@ -134,6 +137,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); try { session.setRead(true); // mark as read when opened @@ -152,6 +156,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes }; await chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open + logService.trace(`[AgentSessions] openSession: provider '${session.providerType}' activated for ${session.resource.toString()}`); let target: typeof SIDE_GROUP | typeof ACTIVE_GROUP | typeof ChatViewPaneTarget | undefined; if (openOptions?.sideBySide) { @@ -168,6 +173,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes return await chatWidgetService.openSession(session.resource, target, options); } catch (error) { + logService.error(`[AgentSessions] openSession failed: ${session.resource.toString()}`, error); notificationService.error(localize('chat.openSessionFailed', "Failed to open chat session: {0}", toErrorMessage(error))); return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index a5645c446c07e1..1faeba2ee7c027 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1252,6 +1252,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ { const existingSessionData = this._sessions.get(sessionResource); if (existingSessionData) { + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: cache hit for ${sessionResource.toString()}`); return existingSessionData.session; } } @@ -1293,7 +1294,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ dispose: () => { } }; } else { + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: resolving content from provider '${resolvedType}' for ${sessionResource.toString()}`); session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: provider returned ${session.history.length} history item(s) for ${sessionResource.toString()}`); } if (session.options) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 616f1effecf586..a92f0f7580dec3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -417,6 +417,14 @@ export class ChatWidget extends Disposable implements IChatWidget { private _isRenderingWelcome = false; private _isLoading = false; + /** + * The session whose model was just bound, cleared by the first + * {@link onDidChangeItems} that renders it. Tracked by resource (rather than + * a flag) so the trace marks time-to-first-render for the model it belongs + * to, once, even when an outgoing model triggers a render while unbinding. + */ + private _pendingFirstRenderSessionResource: URI | undefined; + // Coding agent locking state private _lockedAgent?: { id: string; @@ -461,7 +469,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._viewModel = viewModel; if (viewModel) { this.viewModelDisposables.add(viewModel); - this.logService.debug('ChatWidget#setViewModel: have viewModel'); + this.logService.debug(`ChatWidget#setViewModel: have viewModel session=${viewModel.sessionResource.toString()} requests=${viewModel.model.getRequests().length}`); // If switching to a model with a request in progress, play progress sound if (viewModel.model.requestInProgress.get()) { @@ -1340,6 +1348,11 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.setVisibleChangeCount(this.visibleChangeCount); this.listWidget.refresh(); + if (this._pendingFirstRenderSessionResource && this.viewModel && isEqual(this.viewModel.sessionResource, this._pendingFirstRenderSessionResource)) { + this._pendingFirstRenderSessionResource = undefined; + this.logService.trace(`ChatWidget#firstRender: session=${this.viewModel.sessionResource.toString()} items=${items.length}`); + } + if (!skipDynamicLayout && this._dynamicMessageLayoutData) { this.layoutDynamicChatTreeItemMode(); } @@ -2596,6 +2609,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const currentInputModel = this.viewModel?.model?.inputModel?.state?.get(); if (!model) { + this._pendingFirstRenderSessionResource = undefined; logChangesToStateModel(this.viewModel?.model?.inputModel, `ChatWidget.setModel to empty, old ${this.viewModel?.sessionResource.toString()}`, undefined, currentInputModel, this.logService); // Flush any unsent draft to the outgoing input model before we drop our // reference to it, so the host's `willDisposeModel` persistence sees it. @@ -2643,6 +2657,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.listWidget.setViewModel(this.viewModel); + // Armed only once the list is bound, so a render triggered while the + // outgoing model was torn down cannot consume it. + this._pendingFirstRenderSessionResource = model.sessionResource; if (this._lockedAgent) { let placeholder = this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id)?.inputPlaceholder; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b7205ea6d3c378..d297690e19d961 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -677,11 +677,13 @@ export class ChatService extends Disposable implements IChatService { } private async loadRemoteSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken, debugOwner?: string): Promise { + this.trace('loadRemoteSession', `start ${sessionResource.toString()}`); // Check if session already exists before resolving the provider, // so we can return a cached model even if the provider was unregistered. { const existingRef = this.acquireExistingSession(sessionResource, debugOwner); if (existingRef) { + this.trace('loadRemoteSession', `reused existing model for ${sessionResource.toString()}`); return existingRef; } } @@ -691,6 +693,7 @@ export class ChatService extends Disposable implements IChatService { } const providedSession = await this.chatSessionService.getOrCreateChatSession(sessionResource, token); + this.trace('loadRemoteSession', `session content resolved for ${sessionResource.toString()} with ${providedSession.history.length} history item(s)`); // Make sure we haven't created this in the meantime { @@ -919,6 +922,7 @@ export class ChatService extends Disposable implements IChatService { } } } + this.trace('loadRemoteSession', `history applied to model for ${sessionResource.toString()}: ${model.getRequests().length} request(s)`); // Set up progress streaming and cancellation for contributed sessions. // This handles both the initial in-flight response (from session load) From 8a537d3a1d9377d08cc2d1745830103b18d84e79 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 21 Aug 2026 10:59:18 +0200 Subject: [PATCH 4/9] agentHost: isolate tool approval test workspace (#331921) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/test/node/mockAgent.ts | 10 +++++++--- .../node/protocol/toolApproval.integrationTest.ts | 14 ++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index bb4f35c414038a..00835e39c023fb 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -34,6 +34,10 @@ function mockProject(provider: AgentProvider) { return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; } +function mockWorkspacePath(relativePath: string): string { + return join(process.env['VSCODE_AGENT_HOST_MOCK_WORKSPACE'] ?? process.cwd(), relativePath); +} + interface IMockSendMessageCall { readonly session: URI; readonly prompt: string; @@ -710,7 +714,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: join(process.cwd(), 'src/app.ts') })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: mockWorkspacePath('src/app.ts') })); // Auto-approved writes resolve immediately — complete the tool and turn await timeout(10); this._fireSequence([ @@ -729,7 +733,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: join(process.cwd(), '.env'), confirmationTitle: 'Write .env' })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: mockWorkspacePath('.env'), confirmationTitle: 'Write .env' })); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { @@ -817,7 +821,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: join(process.cwd(), 'file.ts') })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: mockWorkspacePath('file.ts') })); })(); this._pendingPermissions.set('tc-orphan', (approved) => { if (approved) { diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index b82f7f03968c8b..19fdb7746a8cb7 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; @@ -24,15 +27,18 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { let server: IServerHandle; let client: TestProtocolClient; + let workspace: string; suiteSetup(async function () { this.timeout(getAgentHostE2ETestTimeout(15_000, 60_000)); - server = await startServer(); + workspace = mkdtempSync(join(tmpdir(), 'agent-host-tool-approval-')); + server = await startServer({ env: { VSCODE_AGENT_HOST_MOCK_WORKSPACE: workspace } }); }); suiteTeardown(async function () { this.timeout(getAgentHostE2ETestTimeout(20_000, 50_000)); await stopServer(server); + rmSync(workspace, { recursive: true, force: true }); }); setup(async function () { @@ -82,7 +88,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('auto-approves write to regular file (no pending confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', URI.file(workspace).toString()); client.clearReceived(); // Start a turn that triggers a write permission request for a regular .ts file @@ -108,7 +114,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('blocks write to .env file (requires manual confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', URI.file(workspace).toString()); client.clearReceived(); // Start a turn that tries to write .env (blocked by default patterns) @@ -196,7 +202,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { test('dispatches pending_confirmation that arrives without an active turn (does not hang)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', URI.file(workspace).toString()); client.clearReceived(); // The mock completes the turn, then simulates a hook-triggered From 5fe7ec46056d32de3f3f9e5cd34eb22d3abd6e2a Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 21 Aug 2026 11:03:33 +0200 Subject: [PATCH 5/9] Test Agents Window through a Kerberos-authenticated proxy (#331802) test: add Kerberos proxy smoke coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-darwin-test.yml | 5 + .../steps/product-build-darwin-test.yml | 8 + test/smoke/network-proxy/test-kerberos.pac | 8 + .../run-agents-window-network-proxy.sh | 146 +++++++++++++++++- .../src/networkProxy/negotiateAuthHelper.ts | 41 +++++ 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 test/smoke/network-proxy/test-kerberos.pac create mode 100644 test/smoke/src/networkProxy/negotiateAuthHelper.ts diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 0a6f018cf7b538..c8d2ff95d9b36c 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -215,6 +215,11 @@ jobs: timeout-minutes: 40 run: bash test/smoke/scripts/run-agents-window-network-proxy.sh + - name: 🧪 Run Agents Window smoke tests through Kerberos-authenticated macOS PAC proxy + if: ${{ inputs.electron_tests && inputs.smoke_tests }} + timeout-minutes: 40 + run: bash test/smoke/scripts/run-agents-window-network-proxy.sh --kerberos + - name: 🧪 Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 83beb72dae28ee..f5debbc4b3883b 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -123,6 +123,14 @@ steps: timeoutInMinutes: 40 displayName: 🧪 Run Agents Window smoke tests through macOS PAC proxy + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) + APP_NAME="`ls $APP_ROOT | head -n 1`" + bash test/smoke/scripts/run-agents-window-network-proxy.sh --kerberos --build "$APP_ROOT/$APP_NAME" + timeoutInMinutes: 40 + displayName: 🧪 Run Agents Window smoke tests through Kerberos-authenticated macOS PAC proxy + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: npm run smoketest-no-compile -- --web --tracing --headless env: diff --git a/test/smoke/network-proxy/test-kerberos.pac b/test/smoke/network-proxy/test-kerberos.pac new file mode 100644 index 00000000000000..7cac145be35262 --- /dev/null +++ b/test/smoke/network-proxy/test-kerberos.pac @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +function FindProxyForURL(url, host) { + return 'PROXY localhost:43144'; +} diff --git a/test/smoke/scripts/run-agents-window-network-proxy.sh b/test/smoke/scripts/run-agents-window-network-proxy.sh index f73e5a3d118b52..f172c16db4c0fd 100644 --- a/test/smoke/scripts/run-agents-window-network-proxy.sh +++ b/test/smoke/scripts/run-agents-window-network-proxy.sh @@ -11,19 +11,37 @@ FIXTURE_DIR="$ROOT/test/smoke/network-proxy" LOG_DIR="$ROOT/.build/logs/agents-window-network-proxy" TEMP_ROOT="${RUNNER_TEMP:-${AGENT_TEMPDIRECTORY:-${TMPDIR:-/tmp}}}/vscode-agents-window-network-proxy-$$" TEST_REPO="$TEMP_ROOT/vscode-smoketest-express" +PROXY_AUTH="none" +if [[ "${1:-}" == "--kerberos" ]]; then + PROXY_AUTH="kerberos" + shift +fi PROXY_GROUP="vscodeproxytest" PF_ANCHOR="com.apple/vscodeproxytest" MOCK_HOST="vscode-smoke.test" PROXY_HEADER_VALUE="vscode-smoke-network-proxy-$$" PAC_URL="http://127.0.0.1:44444/test.pac" +PAC_FILE="$FIXTURE_DIR/test.pac" PAC_LOG="$LOG_DIR/pac-server.log" SQUID_ACCESS_LOG="$LOG_DIR/squid-access.log" SQUID_LOG="$LOG_DIR/squid.log" SQUID_PREFIX="$(brew --prefix squid 2>/dev/null || true)" SQUID_BIN="$SQUID_PREFIX/sbin/squid" +NODE_BIN="$(command -v node)" +KDC_BIN="/System/Library/PrivateFrameworks/Heimdal.framework/Helpers/kdc" +KDC_PORT="61088" +KDC_LOG="$LOG_DIR/kdc.log" +KERBEROS_AUTH_LOG="$LOG_DIR/kerberos-auth.log" +KERBEROS_CONFIG="$TEMP_ROOT/krb5.conf" +KERBEROS_CACHE="FILE:$TEMP_ROOT/krb5cc" +KERBEROS_KEYTAB="FILE:$TEMP_ROOT/proxy.keytab" +KERBEROS_REALM="VSCODE.PROXY.TEST" +KERBEROS_USERNAME="PlaceholderUsername" +KERBEROS_PASSWORD="Placeholder" pac_pid="" squid_pid="" +kdc_pid="" primary_service="" saved_pac_url="" saved_pac_enabled="No" @@ -59,6 +77,10 @@ cleanup() { kill "$pac_pid" wait "$pac_pid" fi + if [[ -n "$kdc_pid" ]]; then + kill "$kdc_pid" + wait "$kdc_pid" + fi if $group_created; then sudo dseditgroup -o delete "$PROXY_GROUP" @@ -67,7 +89,7 @@ cleanup() { fi if [[ $exit_code -ne 0 ]]; then - tail -n 100 "$PAC_LOG" "$SQUID_LOG" "$SQUID_ACCESS_LOG" 2>/dev/null + tail -n 100 "$PAC_LOG" "$SQUID_LOG" "$SQUID_ACCESS_LOG" "$KDC_LOG" "$KERBEROS_AUTH_LOG" 2>/dev/null fi rm -rf "$TEMP_ROOT" exit "$exit_code" @@ -94,7 +116,7 @@ saved_pac_url="$(printf '%s\n' "$saved_pac_state" | sed -n 's/^URL: //p')" saved_pac_enabled="$(printf '%s\n' "$saved_pac_state" | sed -n 's/^Enabled: //p')" mkdir -p "$LOG_DIR" "$TEMP_ROOT" -rm -f "$PAC_LOG" "$SQUID_ACCESS_LOG" "$SQUID_LOG" +rm -f "$PAC_LOG" "$SQUID_ACCESS_LOG" "$SQUID_LOG" "$KDC_LOG" "$KERBEROS_AUTH_LOG" git clone --depth 1 https://github.com/microsoft/vscode-smoketest-express "$TEST_REPO" if [[ -z "$SQUID_PREFIX" || ! -x "$SQUID_BIN" ]]; then @@ -102,6 +124,66 @@ if [[ -z "$SQUID_PREFIX" || ! -x "$SQUID_BIN" ]]; then exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + PAC_FILE="$FIXTURE_DIR/test-kerberos.pac" + if [[ ! -x "$KDC_BIN" ]]; then + echo "The macOS Heimdal KDC is required at $KDC_BIN" >&2 + exit 1 + fi + if nc -z 127.0.0.1 "$KDC_PORT"; then + echo "Port $KDC_PORT must be available for the Kerberos KDC" >&2 + exit 1 + fi + + cat > "$KERBEROS_CONFIG" < "$KDC_LOG" 2>&1 & + kdc_pid=$! + + kdc_ready=false + for _ in {1..50}; do + if nc -z 127.0.0.1 "$KDC_PORT"; then + kdc_ready=true + break + fi + sleep 0.1 + done + if ! $kdc_ready; then + echo "The local Kerberos KDC did not become ready" >&2 + exit 1 + fi + + printf '%s\n' "$KERBEROS_PASSWORD" > "$TEMP_ROOT/kerberos-password" + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5CCNAME="$KERBEROS_CACHE" \ + /usr/bin/kinit --password-file="$TEMP_ROOT/kerberos-password" "$KERBEROS_USERNAME" +fi + cat > "$TEMP_ROOT/hosts" < "$TEMP_ROOT/squid.conf" <> "$TEMP_ROOT/squid.conf" <> "$TEMP_ROOT/squid.conf" <&2 exit 1 fi -node "$ROOT/test/smoke/out/networkProxy/pacServer.js" "$FIXTURE_DIR/test.pac" > "$PAC_LOG" 2>&1 & +node "$ROOT/test/smoke/out/networkProxy/pacServer.js" "$PAC_FILE" > "$PAC_LOG" 2>&1 & pac_pid=$! -"$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5_KTNAME="$KERBEROS_KEYTAB" "$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +else + "$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +fi squid_pid=$! proxy_ready=false @@ -193,6 +297,12 @@ restricted_env=( "TMPDIR=${TMPDIR:-/tmp}" "USER=$(id -un)" ) +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + restricted_env+=( + "KRB5_CONFIG=$KERBEROS_CONFIG" + "KRB5CCNAME=$KERBEROS_CACHE" + ) +fi for name in BUILD_ARTIFACTSTAGINGDIRECTORY CI GITHUB_ACTIONS GITHUB_RUN_ATTEMPT GITHUB_RUN_ID GITHUB_WORKSPACE RUNNER_TEMP TF_BUILD; do if [[ -n "${!name:-}" ]]; then restricted_env+=("$name=${!name}") @@ -219,6 +329,15 @@ if run_restricted curl --fail --silent --connect-timeout 3 --noproxy '*' http:// exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + if curl --fail --silent --connect-timeout 3 --proxy http://localhost:43144 --noproxy '' "$PAC_URL" >/dev/null 2>&1; then + echo "The Kerberos proxy accepted an unauthenticated request" >&2 + exit 1 + fi + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5CCNAME="$KERBEROS_CACHE" \ + curl --fail --silent --connect-timeout 3 --proxy http://localhost:43144 --noproxy '' --proxy-negotiate --proxy-user : "$PAC_URL" >/dev/null +fi + cd "$ROOT" run_restricted env VSCODE_SMOKE_TEST_MOCK_HOST="$MOCK_HOST" VSCODE_SMOKE_TEST_PROXY_HEADER="$PROXY_HEADER_VALUE" \ npm run smoketest-no-compile -- --tracing -g 'Agents Window' --fail-zero --test-repo "$TEST_REPO" --skip-stable-build "$@" @@ -229,6 +348,11 @@ squid_pid="" kill "$pac_pid" wait "$pac_pid" || true pac_pid="" +if [[ -n "$kdc_pid" ]]; then + kill "$kdc_pid" + wait "$kdc_pid" || true + kdc_pid="" +fi if ! grep -Fq 'GET /test.pac' "$PAC_LOG"; then echo "The macOS proxy resolver did not fetch the PAC script" >&2 @@ -238,3 +362,13 @@ if ! grep -Fq "$MOCK_HOST" "$SQUID_ACCESS_LOG"; then echo "The Agents Window smoke test did not reach the mock server through Squid" >&2 exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + if ! grep -Fq "$KERBEROS_USERNAME@$KERBEROS_REALM" "$KERBEROS_AUTH_LOG"; then + echo "Squid did not validate a Kerberos token from the Agents Window smoke test" >&2 + exit 1 + fi + if ! awk -v host="$MOCK_HOST" -v user="$KERBEROS_USERNAME@$KERBEROS_REALM" 'index($0, "TCP_TUNNEL/200") && index($0, "CONNECT " host) && index($0, " " user " ") { found=1 } END { exit !found }' "$SQUID_ACCESS_LOG"; then + echo "The Agents Window smoke test did not establish a Kerberos-authenticated tunnel through Squid" >&2 + exit 1 + fi +fi diff --git a/test/smoke/src/networkProxy/negotiateAuthHelper.ts b/test/smoke/src/networkProxy/negotiateAuthHelper.ts new file mode 100644 index 00000000000000..acbb9746995071 --- /dev/null +++ b/test/smoke/src/networkProxy/negotiateAuthHelper.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as kerberos from 'kerberos'; + +const authLogPath = process.argv[2]; +if (!authLogPath) { + throw new Error('Authentication log path is required'); +} + +async function main(): Promise { + const input = readline.createInterface({ input: process.stdin }); + for await (const request of input) { + const match = /^YR (\S+)$/.exec(request); + if (!match) { + process.stdout.write('BH unsupported-request\n'); + continue; + } + + try { + const server = await kerberos.initializeServer('HTTP@localhost'); + const response = await server.step(match[1]); + if (!server.contextComplete) { + process.stdout.write('BH incomplete-authentication\n'); + continue; + } + + fs.appendFileSync(authLogPath, `${server.username}\n`); + process.stdout.write(`AF ${response || '='} ${server.username}\n`); + } catch (error) { + console.error(error); + process.stdout.write('BH token-validation-failed\n'); + } + } +} + +void main(); From 01bed2ade0f3bf7c4cc875c03f72ed282fa67cab Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 21 Aug 2026 02:06:45 -0700 Subject: [PATCH 6/9] Make metered connection status bar item more visible (#331913) Make metered connection status more visible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/meteredConnection/browser/meteredConnectionStatus.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts index eea62f66bfd193..73e0f6db02ad64 100644 --- a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts @@ -49,6 +49,7 @@ export class MeteredConnectionStatusContribution extends Disposable implements I text: '$(radio-tower)', ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Enabled"), tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some background network activity, including updates, Settings Sync, inline completions, telemetry, and automatic Git operations, is paused to reduce data usage."), + kind: 'warning', command: { id: 'workbench.action.configureMeteredConnection', title: localize('status.meteredConnection.configure', "Configure") From a87cde8f485a4cdc00cbeaced0b60d7af635af98 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 21 Aug 2026 02:06:49 -0700 Subject: [PATCH 7/9] Avoid repeated synchronous layout reads (#331918) Batch and reuse DOM measurements across hot UI paths, replace per-mousemove geometry reads with element boundary events, and move terminal gutter alignment into CSS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/gutterIndicatorView.ts | 15 ++----------- src/vs/platform/hover/browser/hoverWidget.ts | 8 ++++--- .../contributions/mobileMultiDiffView.ts | 21 ++++++++++--------- .../notifications/notificationsToasts.ts | 15 ++++++++----- .../mergeEditor/browser/view/editorGutter.ts | 3 ++- .../browser/view/renderers/webviewPreloads.ts | 3 ++- .../terminal/browser/media/terminal.css | 9 ++++---- .../browser/xterm/markNavigationAddon.ts | 4 ---- 8 files changed, 37 insertions(+), 41 deletions(-) diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index 6e6bba31582bcc..16b432582426ab 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -13,9 +13,7 @@ import { IAccessibilityService } from '../../../../../../../platform/accessibili import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; -import { IEditorMouseEvent } from '../../../../../../browser/editorBrowser.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; -import { Point } from '../../../../../../common/core/2d/point.js'; import { Rect } from '../../../../../../common/core/2d/rect.js'; import { HoverService } from '../../../../../../../platform/hover/browser/hoverService.js'; import { HoverWidget } from '../../../../../../../platform/hover/browser/hoverWidget.js'; @@ -138,17 +136,6 @@ export class InlineEditsGutterIndicator extends Disposable { minContentWidthInPx: constObservable(0), })); - this._register(this._editorObs.editor.onMouseMove((e: IEditorMouseEvent) => { - const state = this._state.get(); - if (state === undefined) { return; } - - const el = this._iconRef.element; - const rect = el.getBoundingClientRect(); - const rectangularArea = Rect.fromLeftTopWidthHeight(rect.left, rect.top, rect.width, rect.height); - const point = new Point(e.event.posx, e.event.posy); - this._isHoveredOverIcon.set(rectangularArea.containsPoint(point), undefined); - })); - this._register(this._editorObs.editor.onDidScrollChange(() => { this._isHoveredOverIcon.set(false, undefined); })); @@ -548,9 +535,11 @@ export class InlineEditsGutterIndicator extends Disposable { }, onmouseenter: () => { + this._isHoveredOverIcon.set(true, undefined); // TODO show hover when hovering ghost text etc. this._showHover(); }, + onmouseleave: () => this._isHoveredOverIcon.set(false, undefined), style: { cursor: 'pointer', zIndex: '20', diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 06c5f99ba012a7..33a8a3dfaf07fa 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -446,6 +446,7 @@ export class HoverWidget extends Widget implements IHoverWidget { private computeXCordinate(target: TargetRect): void { const hoverWidth = this._hover.containerDomNode.clientWidth + Constants.HoverBorderWidth; + const documentElementClientLeft = this._targetDocumentElement.clientLeft; if (this._target.x !== undefined) { this._x = this._target.x; @@ -469,14 +470,15 @@ export class HoverWidget extends Widget implements IHoverWidget { } // Hover is going beyond window towards right end - if (this._x + hoverWidth >= this._targetDocumentElement.clientWidth) { + const documentElementClientWidth = this._targetDocumentElement.clientWidth; + if (this._x + hoverWidth >= documentElementClientWidth) { this._hover.containerDomNode.classList.add('right-aligned'); - this._x = Math.max(this._targetDocumentElement.clientWidth - hoverWidth - Constants.HoverWindowEdgeMargin, this._targetDocumentElement.clientLeft); + this._x = Math.max(documentElementClientWidth - hoverWidth - Constants.HoverWindowEdgeMargin, documentElementClientLeft); } } // Hover is going beyond window towards left end - if (this._x < this._targetDocumentElement.clientLeft) { + if (this._x < documentElementClientLeft) { this._x = target.left + Constants.HoverWindowEdgeMargin; } diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts index acd582010c4486..4810077c0abd42 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts @@ -340,7 +340,8 @@ export class MobileMultiDiffView extends Disposable { return; } - const layout = this.computeCurrentVirtualLayout(); + const viewportHeight = this.scrollWrapper.clientHeight; + const layout = this.computeCurrentVirtualLayout(viewportHeight); this.currentLayout = layout; this.virtualContent.style.height = `${layout.totalHeight}px`; @@ -357,7 +358,7 @@ export class MobileMultiDiffView extends Disposable { for (const item of layout.items) { const state = this.fileStates[item.index]; const section = this.ensureFileSection(state); - this.applyVirtualLayout(section, state, item); + this.applyVirtualLayout(section, state, item, viewportHeight); if (!this.mountedIndexes.has(item.index)) { this.mountedIndexes.add(item.index); } @@ -375,18 +376,18 @@ export class MobileMultiDiffView extends Disposable { } } - private applyVirtualLayout(section: HTMLElement, state: IMobileMultiDiffFileState, item: IMobileMultiDiffVirtualItemLayout): void { + private applyVirtualLayout(section: HTMLElement, state: IMobileMultiDiffFileState, item: IMobileMultiDiffVirtualItemLayout, viewportHeight: number): void { section.style.top = `${item.renderTop}px`; section.style.height = `${item.renderHeight}px`; const bodyOffset = Math.max(0, item.innerOffset - VIRTUALIZER_METRICS.fileHeaderHeight); state.bodyScrollTop = bodyOffset; - state.bodyViewportHeight = Math.max(0, this.scrollWrapper.clientHeight - VIRTUALIZER_METRICS.fileHeaderHeight); + state.bodyViewportHeight = Math.max(0, viewportHeight - VIRTUALIZER_METRICS.fileHeaderHeight); const content = state.content!; content.classList.toggle('mobile-multi-diff-file-content-placeholder', state.loadState !== 'loaded'); if (state.loadState === 'loaded') { content.style.height = ''; content.style.transform = ''; - this.renderLoadedFileContent(state); + this.renderLoadedFileContent(state, viewportHeight); } else { const bodyHeight = Math.max(0, item.renderHeight - VIRTUALIZER_METRICS.fileHeaderHeight); const placeholderHeight = Math.min( @@ -452,12 +453,12 @@ export class MobileMultiDiffView extends Disposable { empty.style.height = `${visibleHeight}px`; } - private renderLoadedFileContent(state: IMobileMultiDiffFileState): void { + private renderLoadedFileContent(state: IMobileMultiDiffFileState, viewportHeight = this.scrollWrapper.clientHeight): void { if (!state.content || !state.renderData) { return; } - const bodyOverscan = Math.max(this.scrollWrapper.clientHeight, 480); + const bodyOverscan = Math.max(viewportHeight, 480); const visibleTop = Math.max(0, state.bodyScrollTop - bodyOverscan); const visibleBottom = Math.min( state.renderData.bodyHeight, @@ -488,11 +489,11 @@ export class MobileMultiDiffView extends Disposable { }; } - private computeCurrentVirtualLayout(): ReturnType { + private computeCurrentVirtualLayout(viewportHeight: number): ReturnType { return computeMobileMultiDiffVirtualLayout(this.fileStates.map(state => this.toVirtualItem(state)), { - viewportHeight: this.scrollWrapper.clientHeight, + viewportHeight, scrollTop: this.scrollWrapper.scrollTop, - overscan: Math.max(this.scrollWrapper.clientHeight, 480), + overscan: Math.max(viewportHeight, 480), metrics: VIRTUALIZER_METRICS, }); } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index 857ec74cde8293..2a737151e75f06 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -661,15 +661,20 @@ export class NotificationsToasts extends Themable implements INotificationsToast let singleToastHeightToGive = heightToGive; let multipleToastsHeightToGive = Math.round(heightToGive * 0.618); - let visibleToasts = 0; - for (const toast of this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE)) { - + const toasts = this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE); + for (const toast of toasts) { // In order to measure the client height, the element cannot have display: none toast.container.style.opacity = '0'; this.updateToastVisibility(toast, true); + } - singleToastHeightToGive -= toast.container.offsetHeight; - multipleToastsHeightToGive -= toast.container.offsetHeight; + const toastHeights = toasts.map(toast => toast.container.offsetHeight); + let visibleToasts = 0; + for (let i = 0; i < toasts.length; i++) { + const toast = toasts[i]; + const toastHeight = toastHeights[i]; + singleToastHeightToGive -= toastHeight; + multipleToastsHeightToGive -= toastHeight; let makeVisible = false; if (visibleToasts === NotificationsToasts.MAX_NOTIFICATIONS) { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts index 7f92d3f354cc60..7d9a488066a133 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts @@ -78,6 +78,7 @@ export class EditorGutter extends D this.editorOnDidContentSizeChange.read(reader); const scrollTop = this.scrollTop.read(reader); + const domNodeHeight = this._domNode.clientHeight; const visibleRanges = this._editor.getVisibleRanges(); const unusedIds = new Set(this.views.keys()); @@ -126,7 +127,7 @@ export class EditorGutter extends D view.domNode.style.top = `${top}px`; view.domNode.style.height = `${height}px`; - view.gutterItemView.layout(top, height, 0, this._domNode.clientHeight); + view.gutterItemView.layout(top, height, 0, domNodeHeight); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index f0ad0a8c91b9f4..a33ef06b46eafa 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -657,7 +657,8 @@ async function webviewPreloads(ctx: PreloadContext) { } // if the node is not scrollable, we can continue. We don't check the computed style always as it's expensive - if (window.getComputedStyle(node).overflowY === 'hidden' || window.getComputedStyle(node).overflowY === 'visible') { + const overflowY = window.getComputedStyle(node).overflowY; + if (overflowY === 'hidden' || overflowY === 'visible') { continue; } diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index 9370b48b871f6d..0f9f311a5f35f8 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -74,15 +74,15 @@ .monaco-workbench .xterm { /* All terminals have at least 20px left padding for the gutter */ - padding-left: 20px; + padding-left: var(--vscode-spacing-size200); } .monaco-workbench .xterm .xterm-scrollable-element { /* Offset the scrollable element such that: * - The terminal grid will be positioned to the right of the gutter * - Elements are not hidden in the gutter */ - margin-left: -20px; - padding-left: 20px; + margin-left: calc(-1 * var(--vscode-spacing-size200)); + padding-left: var(--vscode-spacing-size200); } .monaco-workbench .terminal-editor .xterm, @@ -555,6 +555,7 @@ .terminal-scroll-highlight { left: 0; right: 0; + margin-left: calc(-1 * var(--vscode-spacing-size200)); border-left: 5px solid #ffffff; border-left-width: 5px !important; pointer-events: none; @@ -573,7 +574,7 @@ box-sizing: border-box; transform: translateX(3px); pointer-events: none; - margin-left: -20px; + margin-left: calc(-1 * var(--vscode-spacing-size200)); } .terminal-command-guide.top { border-top-left-radius: 1px; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index f3ef66bbe3e920..bbd1786b5398ff 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -11,7 +11,6 @@ import type { Terminal, IMarker, ITerminalAddon, IDecoration, IBufferRange } fro import { timeout } from '../../../../../base/common/async.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TERMINAL_OVERVIEW_RULER_CURSOR_FOREGROUND_COLOR } from '../../common/terminalColorRegistry.js'; -import { getWindow } from '../../../../../base/browser/dom.js'; import { ICurrentPartialCommand, isFullTerminalCommand } from '../../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TerminalContribSettingId } from '../../terminalContribExports.js'; @@ -411,9 +410,6 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } else { element.classList.add('terminal-scroll-highlight'); } - if (this._terminal?.element) { - element.style.marginLeft = `-${getWindow(this._terminal.element).getComputedStyle(this._terminal.element).paddingLeft}`; - } }); // TODO: This is not efficient for a large decorationCount decoration.onDispose(() => { this._navigationDecorations = this._navigationDecorations?.filter(d => d !== decoration); }); From ba1ecdd8b543e2d0d575e170c167d002445b6431 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:11:24 +0200 Subject: [PATCH 8/9] Agents - new session action should appear leftmost (#331924) --- .../contrib/sessions/browser/views/sessionsViewActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index b22a8d4c953c17..20bc543c84dcd2 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -446,7 +446,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { { id: SessionSectionToolbarMenuId, group: 'navigation', - order: 1, + order: 0, when: ContextKeyExpr.and( ChatContextKeys.enabled, SessionSectionHasNonCloudRepositoryContext, @@ -455,7 +455,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { { id: SessionSectionToolbarMenuId, group: 'navigation', - order: 1, + order: 0, when: ContextKeyExpr.and( ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), ContextKeyExpr.or( @@ -585,7 +585,7 @@ abstract class BaseArchiveSectionAction extends Action2 { menu: [{ id: SessionSectionToolbarMenuId, group: 'navigation', - order: 0, + order: 1, // Not on Done itself, and not on the "Chats" (quick chats) section. // Also not on Automations. when: ContextKeyExpr.and( From 3e0d90d1670a2abf954378f65091fe8c43b38be9 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:48:40 +0200 Subject: [PATCH 9/9] Agents - add isExternal and pull request fields to session telemetry (#331927) Adds `isExternal` to every session-scoped telemetry event, adds `pullRequestCount` / `pullRequestStatus` to the session summary event, and stops the `agents/requestSent` session counts from counting the session the request was sent to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/contrib/github/common/types.ts | 25 +++++ .../browser/sessionsLifecycleTracker.ts | 52 +++++++++- .../browser/sessionsTelemetry.contribution.ts | 67 ++++++++++--- .../browser/sessionsLifecycleTracker.test.ts | 98 ++++++++++++++++++- .../sessionsTelemetry.contribution.test.ts | 79 ++++++++++++++- 5 files changed, 294 insertions(+), 27 deletions(-) diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 1080cc97fe0e70..4eb65c425895fd 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -187,6 +187,31 @@ export function computePullRequestIcon(state: GitHubPullRequestState | 'draft', } } +/** Coarse pull request state, recoverable from the icon carried on session GitHub info. */ +export type PullRequestStatus = 'open' | 'closed' | 'merged' | 'draft'; + +/** + * Inverse of {@link computePullRequestIcon}: recovers the coarse pull request + * status from an icon. Returns `undefined` when the icon is missing or is not + * one of the known pull request icons (i.e. the state was never resolved). + */ +export function getPullRequestStatusFromIcon(icon: ThemeIcon | undefined): PullRequestStatus | undefined { + switch (icon?.id) { + case Codicon.gitPullRequestDone.id: + return 'merged'; + case Codicon.gitPullRequestClosed.id: + return 'closed'; + case Codicon.gitPullRequestDraft.id: + return 'draft'; + case Codicon.gitPullRequest.id: + case Codicon.gitPullRequestError.id: + case Codicon.gitPullRequestComment.id: + return 'open'; + default: + return undefined; + } +} + //#endregion //#region Issues diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts index 2bcd980c32d8c9..68d0a8defeb620 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISession } from '../../../services/sessions/common/session.js'; +import { getPullRequestStatusFromIcon, PullRequestStatus } from '../../github/common/types.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; /** Storage key for the cumulative number of times this client has been launched. */ @@ -60,6 +61,9 @@ interface IStoredSessionStats { isolationKind: 'worktree' | 'folder'; hasGitRepository: boolean; isVirtualWorkspace: boolean; + // Optional so rows persisted before the field existed still load; + // `createEntry` always sets it and `buildSummary` defaults it. + isExternal?: boolean; // Topology fields are optional so rows persisted before they existed still // load; `createEntry` always sets them and `buildSummary` defaults them. isMultiRoot?: boolean; @@ -110,6 +114,10 @@ interface IStoredSessionStats { filesChanged: number; linesAdded: number; linesDeleted: number; + // Pull requests observed on the session. Optional so rows persisted before + // the fields existed still load; `buildSummary` defaults them. + pullRequestCount?: number; + pullRequestStatus?: PullRequestStatus; } /** @@ -125,6 +133,7 @@ export interface ISessionLifecycleSummary { workspaceHash: string; hasGitRepository: boolean; isVirtualWorkspace: boolean; + isExternal: boolean; isMultiRoot: boolean; folderCount: number; gitFolderCount: number; @@ -161,6 +170,8 @@ export interface ISessionLifecycleSummary { filesChanged: number; linesAdded: number; linesDeleted: number; + pullRequestCount: number; + pullRequestStatus: PullRequestStatus | undefined; userSessionsTotal: number; userSessionsInWorkspace: number; userSessionsForProvider: number; @@ -214,7 +225,7 @@ export class SessionsLifecycleTracker extends Disposable { entry.firstRequestSentAt = Date.now(); entry.firstRequestSentInThisClient = true; } - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } @@ -237,17 +248,17 @@ export class SessionsLifecycleTracker extends Disposable { bumpCounter(session: ISession, key: SessionLifecycleCounterKey): void { const entry = this._ensure(session); entry[key]++; - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } - /** Refresh observed change summary for a tracked session. No-op when not tracked. */ + /** Refresh observed session state (pull requests, changes) for a tracked session. No-op when not tracked. */ updateSessionState(session: ISession): void { const entry = this._stats.get(session.sessionId); if (!entry) { return; } - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } @@ -314,7 +325,7 @@ export class SessionsLifecycleTracker extends Disposable { return undefined; } if (finalSession) { - this._updateChangesSummary(entry, finalSession); + this._updateObservedState(entry, finalSession); } this._stats.delete(sessionId); this._save(); @@ -375,6 +386,31 @@ export class SessionsLifecycleTracker extends Disposable { return entry; } + /** + * Refreshes the parts of the entry that mirror live session state, so the + * summary reports what was last observed rather than what was known when + * tracking started. + */ + private _updateObservedState(entry: IStoredSessionStats, session: ISession): void { + // Provenance is only known once the session metadata has loaded, which + // may happen after the entry was created. + entry.isExternal = session.isExternal?.get() ?? entry.isExternal ?? false; + this._updatePullRequestState(entry, session); + this._updateChangesSummary(entry, session); + } + + private _updatePullRequestState(entry: IStoredSessionStats, session: ISession): void { + const gitHubInfo = session.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); + if (!gitHubInfo) { + // Keep the last known values: GitHub info is resolved asynchronously + // and is absent for sessions without a GitHub repository. + return; + } + const pullRequests = gitHubInfo.pullRequests; + entry.pullRequestCount = pullRequests?.length ?? (gitHubInfo.pullRequest ? 1 : 0); + entry.pullRequestStatus = getPullRequestStatusFromIcon(gitHubInfo.pullRequest?.icon ?? pullRequests?.[0]?.icon); + } + private _updateChangesSummary(entry: IStoredSessionStats, session: ISession): void { const summary = session.changesSummary?.get(); if (summary) { @@ -460,6 +496,7 @@ function createEntry(session: ISession, appLaunchCount: number): IStoredSessionS isolationKind: hasWorktree ? 'worktree' : 'folder', hasGitRepository: hasGit, isVirtualWorkspace: isVirtual, + isExternal: session.isExternal?.get() ?? false, isMultiRoot: topology.isMultiRoot, folderCount: topology.folderCount, gitFolderCount: topology.gitFolderCount, @@ -495,6 +532,8 @@ function createEntry(session: ISession, appLaunchCount: number): IStoredSessionS filesChanged: 0, linesAdded: 0, linesDeleted: 0, + pullRequestCount: 0, + pullRequestStatus: undefined, }; } @@ -509,6 +548,7 @@ function buildSummary(sessionId: string, entry: IStoredSessionStats, reason: Ses hasGitRepository: entry.hasGitRepository, isVirtualWorkspace: entry.isVirtualWorkspace, // Back-compat: entries persisted before these fields existed default to 0/false. + isExternal: entry.isExternal ?? false, isMultiRoot: entry.isMultiRoot ?? false, folderCount: entry.folderCount ?? 0, gitFolderCount: entry.gitFolderCount ?? 0, @@ -545,6 +585,8 @@ function buildSummary(sessionId: string, entry: IStoredSessionStats, reason: Ses filesChanged: entry.filesChanged, linesAdded: entry.linesAdded, linesDeleted: entry.linesDeleted, + pullRequestCount: entry.pullRequestCount ?? 0, + pullRequestStatus: entry.pullRequestStatus, userSessionsTotal: requestCounters.userSessionsTotal, userSessionsInWorkspace: requestCounters.userSessionsInWorkspace, userSessionsForProvider: requestCounters.userSessionsForProvider, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 49f66163d08d8f..b80746008ae6cd 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -536,6 +536,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe providerId: getSessionsTelemetryProviderId(session.providerId), providerType: session.sessionType, chatCount: session.chats.get().length, + isExternal: session.isExternal?.get() ?? false, }; } @@ -665,6 +666,13 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe const inAll: ISession[] = []; for (const session of allSessions) { + // The anchor session is the subject of the event, so counting it + // would inflate every bucket by one for the very session being + // reported on. Compare by id because providers hand out a fresh + // session object on update. + if (session.sessionId === anchorSession.sessionId) { + continue; + } if (session.isArchived.get()) { continue; } @@ -748,6 +756,7 @@ type SessionFields = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; }; type ChatFields = { @@ -810,6 +819,7 @@ type SessionRequestSentEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; chatModeKind: string; isolationKind: SessionIsolationKind; workspaceHash: string; @@ -852,6 +862,7 @@ type SessionActionEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -874,6 +885,7 @@ type SessionRequestSentClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; chatModeKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Built-in chat mode kind (e.g., ask, agent, edit); empty when no mode is selected.' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; @@ -889,18 +901,18 @@ type SessionRequestSentClassification = { fileAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of file attachments included with the request.' }; imageAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of image attachments included with the request.' }; attachmentKinds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Stringified JSON object mapping each attachment kind (e.g. file, image, symbol) to its count for this request.' }; - currentWorkspaceFolderInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace using folder isolation.' }; - currentWorkspaceFolderUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace using folder isolation.' }; - currentWorkspaceFolderWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace using folder isolation.' }; - currentWorkspaceFolderNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace using folder isolation.' }; - currentWorkspaceInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace across all isolation modes.' }; - currentWorkspaceUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace across all isolation modes.' }; - currentWorkspaceWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace across all isolation modes.' }; - currentWorkspaceNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace across all isolation modes.' }; - allWorkspacesInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions across all workspaces.' }; - allWorkspacesUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions across all workspaces.' }; - allWorkspacesWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input across all workspaces.' }; - allWorkspacesNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done across all workspaces.' }; + currentWorkspaceFolderInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + allWorkspacesInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done across all workspaces, excluding the session this request was sent to.' }; userSessionsTotal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started from the Agents window across all workspaces and providers. Incremented only when `isNewSession` is true.' }; userSessionsInWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started in the current workspace. Incremented only when `isNewSession` is true.' }; userSessionsForProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started for this sessions provider across all workspaces. Incremented only when `isNewSession` is true.' }; @@ -913,6 +925,7 @@ type SessionArchivedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -930,6 +943,7 @@ type SessionUnarchivedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -947,6 +961,7 @@ type SessionDeletedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -964,6 +979,7 @@ type ChatDeletedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -981,6 +997,7 @@ type ChatRenamedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -998,6 +1015,7 @@ type SessionRenamedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1015,6 +1033,7 @@ type CreatePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1032,6 +1051,7 @@ type CreateDraftPullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1049,6 +1069,7 @@ type UpdatePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1066,6 +1087,7 @@ type MergePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1083,6 +1105,7 @@ type CheckoutPullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1100,6 +1123,7 @@ type InitializeRepositoryClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1117,6 +1141,7 @@ type CommitClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1134,6 +1159,7 @@ type CommitAndSyncClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1151,6 +1177,7 @@ type SessionRestoredClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1168,6 +1195,7 @@ type FixCIChecksClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1185,6 +1213,7 @@ type FeedbackAddedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1204,6 +1233,7 @@ type FeedbackAddedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1221,6 +1251,7 @@ type FeedbackConvertedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1241,6 +1272,7 @@ type FeedbackConvertedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1259,6 +1291,7 @@ type FeedbackReplyAddedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1278,6 +1311,7 @@ type FeedbackReplyAddedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1295,6 +1329,7 @@ type FeedbackSubmittedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1317,6 +1352,7 @@ type FeedbackSubmittedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1339,6 +1375,7 @@ type SessionStickinessToggledEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1357,6 +1394,7 @@ type SessionStickinessToggledClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1373,6 +1411,7 @@ type SessionMaximizeToggledEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1391,6 +1430,7 @@ type SessionMaximizeToggledClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1414,6 +1454,7 @@ type SessionSummaryClassification = { workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI the session is tied to, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository, captured the first time the session was observed in this client.' }; isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote), captured the first time the session was observed in this client.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one workspace folder, captured the first time the session was observed in this client.' }; folderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of workspace folders in the session, captured the first time the session was observed in this client (browser-projected metadata).' }; gitFolderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of workspace folders backed by a git repository, captured the first time the session was observed in this client.' }; @@ -1450,6 +1491,8 @@ type SessionSummaryClassification = { filesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of changed files in the session at the moment the summary was emitted.' }; linesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total lines added across all changed files in the session at the moment the summary was emitted.' }; linesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total lines deleted across all changed files in the session at the moment the summary was emitted.' }; + pullRequestCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of pull requests associated with the session as last observed in this client; 0 when the session never had one.' }; + pullRequestStatus: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'State of the session\'s most recent pull request as last observed in this client (open, closed, merged or draft); undefined when the session has no pull request or its state was never resolved.' }; userSessionsTotal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started from the Agents window across all workspaces and providers at the moment the summary was emitted.' }; userSessionsInWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started in the current workspace at the moment the summary was emitted.' }; userSessionsForProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started for this sessions provider across all workspaces at the moment the summary was emitted.' }; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts index ef14d05c64ce04..ad62c2b87a9fb4 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts @@ -6,11 +6,12 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { hash } from '../../../../../base/common/hash.js'; -import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { IChat, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IChat, IGitHubInfo, IGitHubPullRequestRef, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { MAX_TRACKED_SESSIONS, SESSIONS_KEY, SessionsLifecycleTracker } from '../../browser/sessionsLifecycleTracker.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -20,6 +21,7 @@ interface ICreateSessionOptions { workspace?: ISessionWorkspace; changes?: readonly ISessionFileChange[]; changesSummary?: ISessionChangesSummary; + isExternal?: IObservable; } function createSession(id: string, opts: ICreateSessionOptions = {}): ISession { @@ -49,6 +51,7 @@ function createSession(id: string, opts: ICreateSessionOptions = {}): ISession { chats: observableValue(`chats-${id}`, []), mainChat: constObservable(undefined!), capabilities: constObservable({ supportsMultipleChats: false }), + isExternal: opts.isExternal, }; } @@ -63,23 +66,33 @@ function createWorkspace(uri: URI, folders: ISessionFolder[]): ISessionWorkspace }; } -function createFolder(uri: URI, opts: { readonly workTreeUri?: URI; readonly withGitRepository?: boolean } = {}): ISessionFolder { +function createFolder(uri: URI, opts: { readonly workTreeUri?: URI; readonly withGitRepository?: boolean; readonly gitHubInfo?: IGitHubInfo } = {}): ISessionFolder { return { root: uri, workingDirectory: uri, name: 'folder', description: undefined, - gitRepository: (opts.withGitRepository || opts.workTreeUri) + gitRepository: (opts.withGitRepository || opts.workTreeUri || opts.gitHubInfo) ? { uri, workTreeUri: opts.workTreeUri, baseBranchName: undefined, - gitHubInfo: constObservable(undefined), + gitHubInfo: constObservable(opts.gitHubInfo), } : undefined, }; } +function createPullRequestRef(number: number, state: GitHubPullRequestState | 'draft'): IGitHubPullRequestRef { + return { + owner: 'microsoft', + repo: 'vscode', + number, + uri: URI.parse(`https://github.com/microsoft/vscode/pull/${number}`), + icon: computePullRequestIcon(state), + }; +} + suite('SessionsLifecycleTracker', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -352,6 +365,81 @@ suite('SessionsLifecycleTracker', () => { }); }); + test('summary reports whether the session is external, refreshed on later interactions', () => { + const isExternal = observableValue('isExternal', false); + const session = createSession('s1', { isExternal }); + + tracker.recordNewChatRequestSent(session); + isExternal.set(true, undefined); + tracker.bumpCounter(session, 'commit'); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + const plain = createSession('s2'); + tracker.recordNewChatRequestSent(plain); + const plainSummary = tracker.finalize(plain.sessionId, 'archived', plain); + + assert.deepStrictEqual({ + external: summary?.isExternal, + plain: plainSummary?.isExternal, + }, { + external: true, + plain: false, + }); + }); + + test('summary reports the pull request count and the status of the most recent pull request', () => { + const workspaceUri = URI.parse('file:///repo'); + const gitHubInfo: IGitHubInfo = { + owner: 'microsoft', + repo: 'vscode', + pullRequests: [createPullRequestRef(2, GitHubPullRequestState.Merged), createPullRequestRef(1, GitHubPullRequestState.Closed)], + pullRequest: { number: 2, uri: URI.parse('https://github.com/microsoft/vscode/pull/2'), icon: computePullRequestIcon(GitHubPullRequestState.Merged) }, + }; + const workspace = createWorkspace(workspaceUri, [createFolder(workspaceUri, { gitHubInfo })]); + const session = createSession('s1', { workspace }); + + tracker.recordNewChatRequestSent(session); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + const plain = createSession('s2'); + tracker.recordNewChatRequestSent(plain); + const plainSummary = tracker.finalize(plain.sessionId, 'archived', plain); + + assert.deepStrictEqual({ + pullRequestCount: summary?.pullRequestCount, + pullRequestStatus: summary?.pullRequestStatus, + plainPullRequestCount: plainSummary?.pullRequestCount, + plainPullRequestStatus: plainSummary?.pullRequestStatus, + }, { + pullRequestCount: 2, + pullRequestStatus: 'merged', + plainPullRequestCount: 0, + plainPullRequestStatus: undefined, + }); + }); + + test('summary reports a draft pull request status without a resolved main pull request icon', () => { + const workspaceUri = URI.parse('file:///repo'); + const gitHubInfo: IGitHubInfo = { + owner: 'microsoft', + repo: 'vscode', + pullRequests: [createPullRequestRef(7, 'draft')], + }; + const workspace = createWorkspace(workspaceUri, [createFolder(workspaceUri, { gitHubInfo })]); + const session = createSession('s1', { workspace }); + + tracker.recordNewChatRequestSent(session); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + assert.deepStrictEqual({ + pullRequestCount: summary?.pullRequestCount, + pullRequestStatus: summary?.pullRequestStatus, + }, { + pullRequestCount: 1, + pullRequestStatus: 'draft', + }); + }); + test('recordFirstRequestTaskInfo is a no-op when the session is not tracked', () => { const session = createSession('s1'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts index 4adea7f6f366e8..872772442f5d2c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts @@ -7,6 +7,8 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -18,7 +20,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { ISearchService } from '../../../../../workbench/services/search/common/search.js'; import { IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; import { ISessionsTasksService } from '../../../chat/browser/sessionsTasksService.js'; -import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISendRequestSentEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -32,7 +34,18 @@ interface IRequestSentTelemetry { readonly attachmentKinds: string; } -function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry { +interface ISessionCountsTelemetry { + readonly currentWorkspaceInProgress: number; + readonly currentWorkspaceUnread: number; + readonly currentWorkspaceWaitingForInput: number; + readonly currentWorkspaceNotDone: number; + readonly allWorkspacesInProgress: number; + readonly allWorkspacesUnread: number; + readonly allWorkspacesWaitingForInput: number; + readonly allWorkspacesNotDone: number; +} + +function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry & ISessionCountsTelemetry { return typeof data === 'object' && data !== null && typeof Reflect.get(data, 'isNewSession') === 'boolean' @@ -43,6 +56,7 @@ function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry { class TestTelemetryService extends NullTelemetryServiceShape { readonly requestSentEvents: IRequestSentTelemetry[] = []; + readonly sessionCounts: ISessionCountsTelemetry[] = []; override publicLog2(eventName?: string, data?: unknown): void { if (eventName === 'agents/requestSent' && isRequestSentTelemetry(data)) { @@ -52,6 +66,16 @@ class TestTelemetryService extends NullTelemetryServiceShape { totalAttachementCount: data.totalAttachementCount, attachmentKinds: data.attachmentKinds, }); + this.sessionCounts.push({ + currentWorkspaceInProgress: data.currentWorkspaceInProgress, + currentWorkspaceUnread: data.currentWorkspaceUnread, + currentWorkspaceWaitingForInput: data.currentWorkspaceWaitingForInput, + currentWorkspaceNotDone: data.currentWorkspaceNotDone, + allWorkspacesInProgress: data.allWorkspacesInProgress, + allWorkspacesUnread: data.allWorkspacesUnread, + allWorkspacesWaitingForInput: data.allWorkspacesWaitingForInput, + allWorkspacesNotDone: data.allWorkspacesNotDone, + }); } } } @@ -99,10 +123,23 @@ const session = { capabilities: constObservable({ supportsMultipleChats: true }), } satisfies ISession; +function createWorkspace(uri: URI): ISessionWorkspace { + return { + uri, + label: 'ws', + icon: ThemeIcon.fromId('folder'), + folders: [], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +const workspace = createWorkspace(URI.parse('file:///repo')); + suite('SessionsTelemetryContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('logs requestSent for new sessions, new chats, and follow-up messages', async () => { + function setup(sessions: readonly ISession[]): { telemetryService: TestTelemetryService; onDidSendRequest: Emitter } { const onDidSendRequest = disposables.add(new Emitter()); const sessionsManagementService = new class extends mock() { override readonly onWillSendRequest = Event.None; @@ -114,7 +151,7 @@ suite('SessionsTelemetryContribution', () => { override readonly onDidRenameChat = Event.None; override readonly onDidRenameSession = Event.None; override readonly onDidChangeSessions = Event.None; - override getSessions(): ISession[] { return [session]; } + override getSessions(): ISession[] { return [...sessions]; } }(); const sessionsService = new class extends mock() { override readonly visibleSessions = constObservable([]); @@ -147,7 +184,9 @@ suite('SessionsTelemetryContribution', () => { sessionsManagementService, sessionsService, telemetryService, - new class extends mock() { }(), + new class extends mock() { + override readonly extUri = extUri; + }(), storageService, new class extends mock() { }(), new class extends mock() { }(), @@ -158,6 +197,12 @@ suite('SessionsTelemetryContribution', () => { tasksService, )); + return { telemetryService, onDidSendRequest }; + } + + test('logs requestSent for new sessions, new chats, and follow-up messages', async () => { + const { telemetryService, onDidSendRequest } = setup([session]); + onDidSendRequest.fire({ session, chat, isNewSession: true, isNewChat: true, options: { query: 'new session' } }); onDidSendRequest.fire({ session, chat, isNewSession: false, isNewChat: true, options: { query: 'new chat' } }); onDidSendRequest.fire({ @@ -178,4 +223,28 @@ suite('SessionsTelemetryContribution', () => { { isNewSession: false, isNewChat: false, totalAttachementCount: 1, attachmentKinds: '{"generic":1}' }, ]); }); + + test('requestSent session counts exclude the session the request was sent to', async () => { + // The anchor is reported by a fresh session object with in-progress + // state, mirroring what a provider hands out right after a send. + const anchor = { ...session, status: constObservable(SessionStatus.InProgress), isRead: constObservable(false), workspace: constObservable(workspace) }; + const listedAnchor = { ...anchor }; + const otherInSameWorkspace = { ...anchor, sessionId: 'other', resource: URI.parse('test:///other') }; + const otherWorkspaceSession = { ...anchor, sessionId: 'elsewhere', resource: URI.parse('test:///elsewhere'), workspace: constObservable(createWorkspace(URI.parse('file:///other-repo'))) }; + const { telemetryService, onDidSendRequest } = setup([listedAnchor, otherInSameWorkspace, otherWorkspaceSession]); + + onDidSendRequest.fire({ session: anchor, chat, isNewSession: false, isNewChat: false, options: { query: 'hi' } }); + await Promise.resolve(); + + assert.deepStrictEqual(telemetryService.sessionCounts, [{ + currentWorkspaceInProgress: 1, + currentWorkspaceUnread: 1, + currentWorkspaceWaitingForInput: 0, + currentWorkspaceNotDone: 1, + allWorkspacesInProgress: 2, + allWorkspacesUnread: 2, + allWorkspacesWaitingForInput: 0, + allWorkspacesNotDone: 2, + }]); + }); });