diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 6f3e9c75d47d3..0992f5e3e9075 100644 --- a/.agents/skills/launch/SKILL.md +++ b/.agents/skills/launch/SKILL.md @@ -19,8 +19,10 @@ The clone is **slim**: workspace storage, browser caches, file history, cached V ## Prerequisites - macOS or Linux. The launcher is a bash script and depends on `rsync`, `curl`, `nohup`, and Node on `PATH`. The example caller snippets below also use `jq` (parse the JSON output) and `lsof` (kill-by-port fallback) — install those if you plan to use them, but the launcher itself does not require them. -- A VS Code checkout with sources built. Run `npm run compile` once (one-shot) or `npm run watch` for incremental rebuilds. Both build the full client **and** the Copilot extension. The launcher also runs `node build/lib/preLaunch.ts` before starting Code OSS, which auto-runs `npm run compile` if `out/` is missing and downloads Electron + built-in extensions. +- A VS Code checkout with `node_modules/` installed (`npm install` if missing — do **not** symlink from a sibling worktree; that breaks builds in subtle ways). +- A VS Code checkout with sources built. Run `npm run compile` once (one-shot) or `npm run watch` for incremental rebuilds. Both build the full client **and** all built-in extensions under `extensions/`. You must build the full product to run successfully, building just the client is not enough. - An **authenticated** Code OSS profile to seed from. By default the launcher uses `~/.vscode-oss-dev`, which is the user-data-dir the repo's `launch.json` configs use - if the user has ever signed in to Copilot in a dev build, this should work. Only pass `--source-user-data-dir ` (or set `$CODE_OSS_DEV_AUTHED_USER_DATA_DIR`) when you specifically want to seed from a different profile (e.g. your regular `~/Library/Application Support/Code` install). + - If Code OSS launches and needs a sign-in, don't give up! Use the questions tool to ask the user to sign in. - `@playwright/cli` available (it's a devDependency in the vscode repo - `npm install` then use `npx @playwright/cli`). - For debugger work: `dap-cli` on `PATH`. If debugger support would be useful but the `dap-cli` skill is not present, prompt the user to install it from https://github.com/roblourens/dap-cli. - CSS selectors are internal implementation details. If a selector-based `eval` stops working, take a fresh `snapshot`, inspect the current DOM, and update the selector rather than assuming an old one still applies. @@ -329,7 +331,7 @@ Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up. - **"Sent env to running instance. Terminating..."** - The dynamic `--user-data-dir` should prevent this. If you see it, another Code OSS is using the same profile path; pass `--source-user-data-dir` to a different source or check that the temp copy actually happened (`ls "$(jq -r .userDataDir <<<"$INFO")"`). - **Renderer ESM errors / `import { Menu } from 'electron'`** - `ELECTRON_RUN_AS_NODE` is set in your env. The launcher unsets it for the child, but if you spawn `code.sh` yourself, do the same. -- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run compile` (one-shot, also rebuilds the Copilot extension) or `npm run watch` (incremental). +- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run compile` (one-shot, also rebuilds all built-in extensions) or `npm run watch` (incremental). A common cause: you ran `npm run transpile-client` to satisfy unit tests, which populated `out/` but not `extensions/*/out/`, so preLaunch's "is `out/` missing?" check skipped the compile. - **`launch.sh` exits non-zero with a log tail** - either pre-launch failed, `code.sh` died before CDP came up, or CDP never opened within 90s. The tail printed to stderr is from `runDir/code.log` - read it to diagnose. - **Snapshot shows the wrong page or no expected controls** - use `tab-list`, switch with `tab-select ` if needed, then re-snapshot before interacting. - **CLI typing commands complete but the input stays empty** - focus chat with the platform shortcut, use `press` or clipboard paste rather than `fill` / `type`, then verify the input state before sending. diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 5abf179e1bdd6..dff4edad27f0f 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,6 +38,21 @@ } ], "policies": [ + { + "key": "mcp.enterpriseManagedAuth.idp", + "name": "McpEnterpriseManagedAuthIdp", + "category": "InteractiveSession", + "minimumVersion": "1.122", + "localization": { + "description": { + "key": "mcp.enterpriseManagedAuth.idp.policy", + "value": "The OAuth/OIDC IdP configuration used for enterprise-managed Model Context Protocol (MCP) server authentication. Delivered through enterprise policy (Windows Group Policy, macOS managed preferences, Linux `/etc/vscode/policy.json`)." + } + }, + "type": "object", + "default": {}, + "included": false + }, { "key": "chat.mcp.gallery.serviceUrl", "name": "McpGalleryServiceUrl", diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 3040277d65e4f..6388e65759334 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -12,6 +12,8 @@ "--vscode-activityBarBadge-background", "--vscode-activityBarBadge-foreground", "--vscode-activityBarTop-activeBackground", + "--vscode-activeSessionView-background", + "--vscode-activeSessionView-foreground", "--vscode-activityBarTop-activeBorder", "--vscode-activityBarTop-background", "--vscode-activityBarTop-dropBorder", @@ -25,6 +27,8 @@ "--vscode-agentSessionReadIndicator-foreground", "--vscode-agentSessionSelectedBadge-border", "--vscode-agentSessionSelectedUnfocusedBadge-border", + "--vscode-inactiveSessionView-background", + "--vscode-inactiveSessionView-foreground", "--vscode-agentStatusIndicator-background", "--vscode-badge-background", "--vscode-badge-foreground", @@ -947,6 +951,8 @@ "--mobile-diff-tok-keyword", "--mobile-diff-tok-number", "--chat-editing-last-edit-shift", + "--session-view-background", + "--session-view-foreground", "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", diff --git a/build/vite/mobile-multi-diff-worker.ts b/build/vite/mobile-multi-diff-worker.ts new file mode 100644 index 0000000000000..266fed0ab9421 --- /dev/null +++ b/build/vite/mobile-multi-diff-worker.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { computeUnifiedDiff } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.js'; + +interface IComputeDiffRequest { + readonly id: number; + readonly originalText: string; + readonly modifiedText: string; +} + +self.addEventListener('message', (event: MessageEvent) => { + const { id, originalText, modifiedText } = event.data; + try { + self.postMessage({ id, hunks: computeUnifiedDiff(originalText, modifiedText) }); + } catch (error) { + self.postMessage({ id, error: error instanceof Error ? error.message : String(error) }); + } +}); + +self.postMessage({ type: 'ready' }); diff --git a/build/vite/mobile-multi-diff.html b/build/vite/mobile-multi-diff.html new file mode 100644 index 0000000000000..09f34327ba794 --- /dev/null +++ b/build/vite/mobile-multi-diff.html @@ -0,0 +1,103 @@ + + + + + + + + + +
+
+
+ + + + diff --git a/build/vite/mobile-multi-diff.ts b/build/vite/mobile-multi-diff.ts new file mode 100644 index 0000000000000..d6484caa18c55 --- /dev/null +++ b/build/vite/mobile-multi-diff.ts @@ -0,0 +1,549 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable local/code-no-dangerous-type-assertions */ + +// Import the mobile diff view CSS +import '../../src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css'; +import '../../src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css'; + +import { URI } from '../../src/vs/base/common/uri.js'; +import { MobileMultiDiffView, IMobileMultiDiffViewData } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.js'; +import { IFileDiffViewData } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.js'; +import { computeUnifiedDiff, type IDiffHunk } from '../../src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.js'; +import { ITextFileService } from '../../src/vs/workbench/services/textfile/common/textfiles.js'; +import { ILanguageService } from '../../src/vs/editor/common/languages/language.js'; +import { IFileService } from '../../src/vs/platform/files/common/files.js'; +import { VSBuffer } from '../../src/vs/base/common/buffer.js'; + +// --- Sample file contents --- + +const FILES: Record = { + 'inmemory://original/src/greet.ts': `function greet(name: string): string { + return 'Hello, ' + name; +} + +function main() { + console.log(greet('World')); +}`, + + 'inmemory://modified/src/greet.ts': `function greet(name: string, greeting = 'Hello'): string { + return \`\${greeting}, \${name}!\`; +} + +function farewell(name: string): string { + return \`Goodbye, \${name}!\`; +} + +function main() { + console.log(greet('World')); + console.log(farewell('World')); +}`, + + 'inmemory://original/src/config.ts': `export interface Config { + host: string; + port: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 3000, +}; + +export function validateConfig(config: Config): boolean { + if (!config.host) { + return false; + } + if (config.port < 0 || config.port > 65535) { + return false; + } + return true; +} + +export function mergeConfig(base: Config, overrides: Partial): Config { + return { ...base, ...overrides }; +}`, + + 'inmemory://modified/src/config.ts': `export interface Config { + host: string; + port: number; + secure: boolean; + timeout: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 8080, + secure: true, + timeout: 30000, +}; + +export function validateConfig(config: Config): boolean { + if (!config.host) { + throw new Error('Host is required'); + } + if (config.port < 0 || config.port > 65535) { + throw new Error(\`Invalid port: \${config.port}\`); + } + if (config.timeout < 0) { + throw new Error('Timeout must be non-negative'); + } + return true; +} + +export function mergeConfig(base: Config, overrides: Partial): Config { + const merged = { ...base, ...overrides }; + validateConfig(merged); + return merged; +}`, + + 'inmemory://original/src/server.ts': `import { Config } from './config'; + +export function createServer(config: Config) { + return { config }; +}`, + + 'inmemory://modified/src/server.ts': `import { Config } from './config'; + +export function createServer(config: Config) { + const { host, port, secure } = config; + const protocol = secure ? 'https' : 'http'; + console.log(\`Starting server at \${protocol}://\${host}:\${port}\`); + return { config, url: \`\${protocol}://\${host}:\${port}\` }; +}`, + + 'inmemory://original/src/middleware.ts': `import { Request, Response, NextFunction } from 'express'; + +export function logMiddleware(req: Request, res: Response, next: NextFunction) { + console.log(\`\${req.method} \${req.url}\`); + next(); +}`, + + 'inmemory://modified/src/middleware.ts': `import { Request, Response, NextFunction } from 'express'; + +export interface LogOptions { + verbose: boolean; + timestamp: boolean; +} + +const defaultLogOptions: LogOptions = { + verbose: false, + timestamp: true, +}; + +export function logMiddleware(req: Request, res: Response, next: NextFunction, options: LogOptions = defaultLogOptions) { + const timestamp = options.timestamp ? \`[\${new Date().toISOString()}] \` : ''; + const method = req.method; + const url = req.url; + + if (options.verbose) { + console.log(\`\${timestamp}\${method} \${url} - Headers: \${JSON.stringify(req.headers)}\`); + } else { + console.log(\`\${timestamp}\${method} \${url}\`); + } + + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log(\`\${timestamp}\${method} \${url} - \${res.statusCode} (\${duration}ms)\`); + }); + + next(); +} + +export function errorMiddleware(err: Error, req: Request, res: Response, next: NextFunction) { + console.error(\`Error: \${err.message}\`); + res.status(500).json({ error: err.message }); +}`, + + 'inmemory://original/src/utils.ts': `export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function retry(fn: () => Promise, attempts: number): Promise { + return fn().catch(err => { + if (attempts <= 1) throw err; + return retry(fn, attempts - 1); + }); +}`, + + 'inmemory://modified/src/utils.ts': `export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export interface RetryOptions { + attempts: number; + delay: number; + backoff: 'linear' | 'exponential'; +} + +const defaultRetryOptions: RetryOptions = { + attempts: 3, + delay: 1000, + backoff: 'exponential', +}; + +export async function retry(fn: () => Promise, options: RetryOptions = defaultRetryOptions): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < options.attempts; i++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (i < options.attempts - 1) { + const delay = options.backoff === 'exponential' + ? options.delay * Math.pow(2, i) + : options.delay * (i + 1); + await sleep(delay); + } + } + } + + throw lastError; +} + +export function debounce void>(fn: T, ms: number): T { + let timer: ReturnType | undefined; + return ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +}`, +}; + +// --- Worker-backed diff computer --- + +interface IWorkerDiffResponse { + readonly id?: number; + readonly type?: 'ready'; + readonly hunks?: readonly IDiffHunk[]; + readonly error?: string; +} + +interface IWorkerDiffPending { + readonly resolve: (hunks: readonly IDiffHunk[]) => void; + readonly reject: (error: Error) => void; + readonly timeout: number; + readonly prewarm: boolean; +} + +const workerDiffStats = { + requestCount: 0, + completedCount: 0, + prewarmRequestCount: 0, + prewarmCompletedCount: 0, + errorCount: 0, + fallbackCount: 0, + timeoutCount: 0, +}; + +const workerPrewarmOriginal = 'export const mobileDiffWorkerWarmup = 1;\n'; +const workerPrewarmModified = 'export const mobileDiffWorkerWarmup = 2;\n'; + +function updateWorkerDiffStatsDataset(): void { + const dataset = document.documentElement.dataset; + dataset.mobileMultiDiffWorkerRequestCount = String(workerDiffStats.requestCount); + dataset.mobileMultiDiffWorkerCompletedCount = String(workerDiffStats.completedCount); + dataset.mobileMultiDiffWorkerPrewarmRequestCount = String(workerDiffStats.prewarmRequestCount); + dataset.mobileMultiDiffWorkerPrewarmCompletedCount = String(workerDiffStats.prewarmCompletedCount); + dataset.mobileMultiDiffWorkerErrorCount = String(workerDiffStats.errorCount); + dataset.mobileMultiDiffWorkerFallbackCount = String(workerDiffStats.fallbackCount); + dataset.mobileMultiDiffWorkerTimeoutCount = String(workerDiffStats.timeoutCount); +} + +function createWorkerDiffComputer(): ((originalText: string, modifiedText: string) => Promise) | undefined { + updateWorkerDiffStatsDataset(); + if (typeof Worker === 'undefined') { + return undefined; + } + + let worker: Worker; + try { + worker = new Worker(new URL('./mobile-multi-diff-worker.ts', import.meta.url), { type: 'module' }); + } catch { + return (originalText: string, modifiedText: string) => fallbackComputeDiff(originalText, modifiedText); + } + + const pending = new Map(); + let nextId = 1; + let workerFailed: Error | undefined; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + const readyTimeout = window.setTimeout(() => { + failWorker(new Error('Mobile multi-diff worker did not become ready.')); + }, 2000); + const workerReady = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + workerReady.catch(() => undefined); + + worker.addEventListener('message', (event: MessageEvent) => { + const { id, hunks, error } = event.data; + if (event.data.type === 'ready') { + window.clearTimeout(readyTimeout); + resolveReady(); + return; + } + + if (id === undefined) { + return; + } + + const request = pending.get(id); + if (!request) { + return; + } + pending.delete(id); + window.clearTimeout(request.timeout); + workerDiffStats.completedCount++; + if (request.prewarm) { + workerDiffStats.prewarmCompletedCount++; + } + updateWorkerDiffStatsDataset(); + if (error) { + request.reject(new Error(error)); + } else { + request.resolve(hunks ?? []); + } + }); + + worker.addEventListener('error', event => { + failWorker(new Error(event.message || 'Mobile multi-diff worker failed.')); + }); + + worker.addEventListener('messageerror', () => { + failWorker(new Error('Mobile multi-diff worker could not deserialize a message.')); + }); + + window.addEventListener('beforeunload', () => worker.terminate(), { once: true }); + + function failWorker(error: Error): void { + if (workerFailed) { + return; + } + + workerFailed = error; + workerDiffStats.errorCount++; + updateWorkerDiffStatsDataset(); + window.clearTimeout(readyTimeout); + rejectReady(error); + for (const request of pending.values()) { + window.clearTimeout(request.timeout); + request.reject(error); + } + pending.clear(); + worker.terminate(); + } + + function fallbackComputeDiff(originalText: string, modifiedText: string): Promise { + workerDiffStats.fallbackCount++; + updateWorkerDiffStatsDataset(); + return Promise.resolve().then(() => computeUnifiedDiff(originalText, modifiedText)); + } + + function requestWorkerDiff(originalText: string, modifiedText: string, prewarm: boolean): Promise { + const id = nextId++; + workerDiffStats.requestCount++; + if (prewarm) { + workerDiffStats.prewarmRequestCount++; + } + updateWorkerDiffStatsDataset(); + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + if (!pending.delete(id)) { + return; + } + + workerDiffStats.timeoutCount++; + updateWorkerDiffStatsDataset(); + fallbackComputeDiff(originalText, modifiedText).then(resolve, reject); + }, 5000); + pending.set(id, { resolve, reject, timeout, prewarm }); + try { + worker.postMessage({ id, originalText, modifiedText }); + } catch { + window.clearTimeout(timeout); + pending.delete(id); + fallbackComputeDiff(originalText, modifiedText).then(resolve, reject); + } + }).catch(() => { + return fallbackComputeDiff(originalText, modifiedText); + }).finally(() => { + const request = pending.get(id); + if (request) { + window.clearTimeout(request.timeout); + pending.delete(id); + } + }); + } + + void workerReady.then(() => { + window.setTimeout(() => { + if (!workerFailed) { + void requestWorkerDiff(workerPrewarmOriginal, workerPrewarmModified, true); + } + }, 0); + }, () => undefined); + + return async (originalText: string, modifiedText: string) => { + if (workerFailed) { + return fallbackComputeDiff(originalText, modifiedText); + } + + try { + await workerReady; + } catch { + return fallbackComputeDiff(originalText, modifiedText); + } + + return requestWorkerDiff(originalText, modifiedText, false); + }; +} + +// --- Mock services --- + +const readLog: string[] = []; + +function recordRead(uri: URI): void { + readLog.push(uri.toString()); + document.documentElement.dataset.mobileMultiDiffReadCount = String(readLog.length); +} + +const mockTextFileService = { + read(uri: URI) { + recordRead(uri); + const content = FILES[uri.toString()] ?? ''; + return Promise.resolve({ value: content }); + } +} as unknown as ITextFileService; + +const mockFileService = { + readFile(uri: URI) { + const content = FILES[uri.toString()] ?? ''; + return Promise.resolve({ value: VSBuffer.fromString(content) }); + } +} as unknown as IFileService; + +const mockLanguageService = { + guessLanguageIdByFilepathOrFirstLine(uri: URI): string { + const path = uri.path; + if (path.endsWith('.ts') || path.endsWith('.tsx')) { return 'typescript'; } + if (path.endsWith('.js') || path.endsWith('.jsx')) { return 'javascript'; } + if (path.endsWith('.py')) { return 'python'; } + if (path.endsWith('.css')) { return 'css'; } + if (path.endsWith('.html')) { return 'html'; } + if (path.endsWith('.json')) { return 'json'; } + return 'unknown'; + } +} as unknown as ILanguageService; + +Object.assign(globalThis, { + __mobileMultiDiffDebug: { + readLog, + workerDiffStats, + get readCount() { return readLog.length; }, + } +}); + +// --- Build diff data --- + +const diffs: IFileDiffViewData[] = [ + { + originalURI: URI.parse('inmemory://original/src/greet.ts'), + modifiedURI: URI.parse('inmemory://modified/src/greet.ts'), + identical: false, + added: 6, + removed: 2, + }, + { + originalURI: URI.parse('inmemory://original/src/config.ts'), + modifiedURI: URI.parse('inmemory://modified/src/config.ts'), + identical: false, + added: 12, + removed: 5, + }, + { + originalURI: URI.parse('inmemory://original/src/server.ts'), + modifiedURI: URI.parse('inmemory://modified/src/server.ts'), + identical: false, + added: 4, + removed: 1, + }, + { + originalURI: URI.parse('inmemory://original/src/middleware.ts'), + modifiedURI: URI.parse('inmemory://modified/src/middleware.ts'), + identical: false, + added: 30, + removed: 2, + }, + { + originalURI: URI.parse('inmemory://original/src/utils.ts'), + modifiedURI: URI.parse('inmemory://modified/src/utils.ts'), + identical: false, + added: 32, + removed: 5, + }, +]; + +function createLargeScenario(fileCount: number, lineCount: number): IFileDiffViewData[] { + const result: IFileDiffViewData[] = []; + for (let fileIndex = 0; fileIndex < fileCount; fileIndex++) { + const originalURI = URI.parse(`inmemory://original/large/file${fileIndex}.ts`); + const modifiedURI = URI.parse(`inmemory://modified/large/file${fileIndex}.ts`); + FILES[originalURI.toString()] = createLargeFileText(fileIndex, lineCount, false); + FILES[modifiedURI.toString()] = createLargeFileText(fileIndex, lineCount, true); + result.push({ + originalURI, + modifiedURI, + identical: false, + added: lineCount, + removed: lineCount, + }); + } + return result; +} + +function createLargeFileText(fileIndex: number, lineCount: number, modified: boolean): string { + const lines: string[] = []; + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const value = modified ? lineIndex + 1000 : lineIndex; + lines.push(`export const file${fileIndex}Value${lineIndex} = ${value};`); + } + return lines.join('\n'); +} + +// --- Render --- + +function init() { + const container = document.getElementById('container')!; + const params = new URLSearchParams(location.search); + const useLargeScenario = params.has('large'); + const fileCount = Math.max(1, Number(params.get('files') ?? 50)); + const lineCount = Math.max(1, Number(params.get('lines') ?? 500)); + const scenarioDiffs = useLargeScenario ? createLargeScenario(fileCount, lineCount) : diffs; + const computeDiff = params.get('worker') === '0' ? undefined : createWorkerDiffComputer(); + document.documentElement.dataset.mobileMultiDiffWorkerDiff = computeDiff ? 'true' : 'false'; + + const data: IMobileMultiDiffViewData = { + diffs: scenarioDiffs, + initialIndex: 0, + computeDiff, + }; + + const view = new MobileMultiDiffView(container, data, mockTextFileService, mockFileService, mockLanguageService); + + // Clean up on page unload + window.addEventListener('beforeunload', () => view.dispose()); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md index a59226d5d7834..a2e2c2f265146 100644 --- a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -104,35 +104,39 @@ When the user asks for cost tips, ways to reduce token usage, or how to lower Co The goal is **personalized, data-grounded recommendations** for reducing token usage — not a generic checklist. Every tip must point to a specific pattern you observed in their data. -**Cost-relevant schema (in addition to the Database Schema section below)** +**Scope: focus on VS Code chat sessions** -- **Cloud DuckDB only** — the local SQLite store does **not** record per-event token usage and has no `events` table. If the active backend is local, gate all token queries and tell the user that real token-level analysis requires enabling cloud sync (`chat.sessionSync.enabled`). -- **events** (cloud): per-event billing — rows where `type = 'assistant.usage'` carry `usage_input_tokens`, `usage_output_tokens`, `usage_model`. To break spend down by agent type, JOIN `events e` to `sessions s ON s.id = e.session_id` and group by `s.agent_name`. -- **sessions.agent_name** / **agent_description** (both backends): values like `VS Code agent` (VS Code chat), `Copilot CLI`, `Copilot Coding Agent`, `Copilot Code Review`, or custom agents/subagents. Use to break spend down by agent type. -- Use `LENGTH(user_message)` on `turns` (or `LENGTH(user_content)` on `events` where `type = 'user.message'`) to find oversized pastes. +Other agent surfaces (Copilot CLI, Copilot Coding Agent, Copilot Code Review, custom agents/subagents) have very different cost profiles and would skew the analysis. By default, **filter every query to the interactive VS Code chat surface** so findings reflect that usage only. Only widen the scope if the user explicitly asks about CLI, Coding Agent, or custom agents — and when you do, run separate queries per agent type rather than mixing them. + +The stored `agent_name` differs by backend — match the active backend's value **exactly** (case and spacing matter): + +- **Cloud (DuckDB)**: `sessions.agent_name = 'VS Code Chat'` +- **Local (SQLite)**: `sessions.agent_name = 'GitHub Copilot Chat'`. Local also records subagent invocations (e.g. `Explore`, `summarizeConversationHistory`) as their own session rows; the default filter correctly excludes them. -**Step 1: Investigate cost and token patterns** +Briefly check the agent mix once so you know what's being excluded (e.g. `SELECT agent_name, COUNT(*) AS n FROM sessions WHERE updated_at > <30-day cutoff> GROUP BY 1 ORDER BY n DESC`). If the interactive chat value is a small minority of the user's sessions, mention that in the summary so they know the tips are scoped to a slice of their activity, and **offer to run a separate pass on another agent type** — name the candidates you saw in the mix check (e.g. "want a separate pass on `Copilot CLI` or `Copilot Coding Agent`?") so the user knows widening is possible. -Use `copilot_sessionStoreSql` with `action: "query"`. What to investigate depends on the active backend. +If the user asks to widen scope to a specific surface (e.g. "now do CLI", "cost tips for my Coding Agent sessions", "include my `Explore` subagent"), swap the default `agent_name` filter for the requested value and run the analysis against that slice **only** — do not mix surfaces in one pass. Use the exact `agent_name` strings shown by the mix-check above; common values across backends include `Copilot CLI` / `copilotcli`, `Copilot Coding Agent`, and any custom agent / subagent name (e.g. `Explore`, `summarizeConversationHistory`). Note in the summary that the tips are now scoped to that surface and call out anything you can't analyze on the active backend (e.g. cloud-only token columns when the user is on local). -*Cloud (DuckDB) — start with agent-type awareness:* +**Cost-relevant schema (in addition to the Database Schema section below)** + +- **Cloud DuckDB only** — the local SQLite store does **not** record per-event token usage and has no `events` table. If the active backend is local, gate all token queries and tell the user that real token-level analysis requires enabling cloud sync (`chat.sessionSync.enabled`). +- **events** (cloud): per-event billing — rows where `type = 'assistant.usage'` carry `usage_input_tokens`, `usage_output_tokens`, `usage_model`. JOIN `events e` to `sessions s ON s.id = e.session_id` and filter `WHERE s.agent_name = 'VS Code Chat'` to keep the scope tight. +- **sessions.agent_name** / **agent_description** (both backends): the interactive VS Code chat surface is stored as `'VS Code Chat'` on cloud and `'GitHub Copilot Chat'` on local. Other values include `Copilot CLI` / `copilotcli`, `Copilot Coding Agent`, subagents (`Explore`, `summarizeConversationHistory`, `panel/editAgent`, …), and custom agents. +- Use `LENGTH(user_message)` on `turns` (or `LENGTH(user_content)` on `events` where `type = 'user.message'`) to find oversized pastes. -The session store mixes session types via `sessions.agent_name` (join events to sessions on `session_id` to get the agent for any per-event analysis). Your advice is only useful if you know which agents the user actually runs, so this is the **first** thing to learn. +**Step 1: Investigate cost and token patterns (interactive VS Code chat only)** -- **Enumerate every agent in use.** Run e.g. `SELECT agent_name, agent_description, COUNT(*) AS n FROM sessions WHERE updated_at > now() - INTERVAL '30 days' GROUP BY 1, 2 ORDER BY n DESC` so you see the full inventory — official agents and any custom agents/subagents in `agent_description`. Do not assume. -- **Decide which to advise on.** Include any agent type the user can make cheaper: `VS Code agent` (VS Code chat — usually the dominant agent), `Copilot CLI` (interactive terminal), `Copilot Coding Agent` (autonomous cloud tasks), custom agents and subagents. **Always exclude** `agent_name = 'Copilot Code Review'` and any other agent the user does not drive interactively. -- **Tailor advice per agent.** VS Code agent tips (compaction, model picker, fresh chats, `.github/copilot-instructions.md`, custom skills/agents) look different from CLI tips (compaction, model switching, subagent delegation), Coding Agent tips (prompt scoping, smaller task framing), and custom-agent tips (slimming tool lists, narrowing prompts). +Use `copilot_sessionStoreSql` with `action: "query"`. Every query in this step must filter `sessions.agent_name` to the interactive VS Code chat value for the active backend — `'VS Code Chat'` on cloud, `'GitHub Copilot Chat'` on local. What to investigate depends on the active backend. -Then drill into cost patterns (filter `events` rows by `type = 'assistant.usage'` for billable rows): +*Cloud (DuckDB) — drill into cost patterns* (filter `events` rows by `type = 'assistant.usage'` for billable rows, and join `sessions` to keep `agent_name = 'VS Code Chat'`): - **Token-heavy sessions and turns** — sum `usage_input_tokens` and `usage_output_tokens` per session and per model from `events` where `type = 'assistant.usage'`. Which sessions burned the most tokens? Which models? - **Input-to-output ratios** — when input tokens dwarf output tokens, the user is paying to re-send a bloated context every turn. Strongest signal that compaction, smaller working sets, or fresh sessions would help. - **Model mix** — break down spend by `usage_model`. Are premium models being used for routine work (renames, simple edits, status checks) that a cheaper model could handle? - **Per-turn growth** — within long sessions, does `usage_input_tokens` keep climbing turn-over-turn? Strong signal that compaction wasn't used. - **Oversized pastes** — `LENGTH(user_content)` on `events` where `type = 'user.message'` to find user messages that should have been file references (also visible in `session_files` as repeated reads of the same path within one session). -- **Group cost breakdowns by `agent_name`** (and `agent_description` where useful) in at least one query so the user sees where their spend actually goes — and so you spot if a single custom agent dominates. -*Local (SQLite) — no token data; use proxies:* +*Local (SQLite) — no token data; use proxies* (filter `sessions.agent_name = 'GitHub Copilot Chat'` on every query): - **Long sessions without compaction** — sessions with many turns and no rows in `checkpoints` (each `checkpoints` row is a successful compaction). `LEFT JOIN checkpoints c ON c.session_id = s.id WHERE c.session_id IS NULL` + a turn-count threshold gives prime candidates. - **Late compaction** — for sessions that *do* have checkpoints, compare `checkpoints.checkpoint_number` and `created_at` against the session's turn count. A first compaction at turn 60 of an 80-turn session is far less helpful than one at turn 25. @@ -168,9 +172,9 @@ Give the user 3-5 specific, actionable tips. Each tip should: - **Be non-obvious** — skip basics any returning user already knows. Assume they know compaction and fresh chats exist; help them notice they're not *using* them where it would matter. - **Quantify the win when possible** — "compacting around turn 30 of that 80-turn session would have shaved ~X input tokens off every subsequent turn" is far better than "consider compacting". - **Be concrete** — name the workflow change, command, or config file edit. If the suggestion is a custom skill or agent, sketch what it would cover. -- **Match the agent type** — if a finding is specific to one `agent_name`, say so. Don't propose CLI-only fixes for findings from Coding Agent sessions, and vice versa. +- **Stay within VS Code chat scope** — tips should target interactive VS Code chat usage (compaction, model picker, fresh chats, `.github/copilot-instructions.md`, custom skills/agents, subagent delegation). Do not propose CLI- or Coding-Agent–specific changes unless the user has explicitly broadened scope. -If the session store has little data (e.g., cloud store is empty, or only a handful of local sessions), say so plainly and offer 2-3 non-obvious cost-saving habits anchored in available features rather than fabricating findings. If the user is on local-only storage, end by noting that enabling `chat.sessionSync.enabled` unlocks per-event token analysis for sharper future tips. +If the session store has little data (e.g., cloud store is empty, or only a handful of local interactive chat sessions), say so plainly and offer 2-3 non-obvious cost-saving habits anchored in available features rather than fabricating findings. If the user is on local-only storage, end by noting that enabling `chat.sessionSync.enabled` unlocks per-event token analysis for sharper future tips. ### Improve diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 9eb084419d520..0a25634f7d10c 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -17309,9 +17309,9 @@ } }, "node_modules/tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", "dev": true, "license": "MIT", "engines": { diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts b/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts index d62999e40bbfa..1e916c5eda139 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts @@ -2,7 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { commands, env, Uri } from 'vscode'; +import { chat, commands, env, Uri } from 'vscode'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IChatQuotaService } from '../../../platform/chat/common/chatQuotaService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IExtensionContribution } from '../../common/contributions'; @@ -10,7 +11,10 @@ import { IExtensionContribution } from '../../common/contributions'; export class ChatQuotaContribution extends Disposable implements IExtensionContribution { public readonly id = 'chat.quota'; - constructor(@IChatQuotaService chatQuotaService: IChatQuotaService) { + constructor( + @IChatQuotaService chatQuotaService: IChatQuotaService, + @IAuthenticationService authService: IAuthenticationService, + ) { super(); this._register(commands.registerCommand('chat.enableAdditionalUsage', () => { // Clear quota before opening the page to ensure that if the user enabled additional usage, @@ -18,5 +22,36 @@ export class ChatQuotaContribution extends Disposable implements IExtensionContr chatQuotaService.clearQuota(); env.openExternal(Uri.parse('https://aka.ms/github-copilot-manage-overage')); })); + + // Extension → Core: push updated quota state to core whenever it changes + // (e.g. from response headers, quota snapshots, or copilot token refresh). + this._register(chatQuotaService.onDidChange(() => { + const info = chatQuotaService.quotaInfo; + if (!info) { + return; + } + + const isFree = !!authService.copilotToken?.isFreeUser; + const snapshot = { + percentRemaining: info.percentRemaining, + unlimited: info.unlimited, + hasQuota: info.hasQuota, + entitlement: info.quota, + }; + + const { session, weekly } = chatQuotaService.rateLimitInfo; + + const quotas = { + usageBasedBilling: !!authService.copilotToken?.isUsageBasedBilling, + chat: isFree ? snapshot : undefined, + premiumChat: isFree ? undefined : snapshot, + additionalUsageEnabled: info.additionalUsageEnabled, + additionalUsageCount: info.additionalUsageUsed, + sessionRateLimit: session ? { percentRemaining: session.percentRemaining, unlimited: session.unlimited, resetDate: session.resetDate.toISOString() } : undefined, + weeklyRateLimit: weekly ? { percentRemaining: weekly.percentRemaining, unlimited: weekly.unlimited, resetDate: weekly.resetDate.toISOString() } : undefined, + }; + + chat.updateQuotas(quotas); + })); } } diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts deleted file mode 100644 index d2503875573a2..0000000000000 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts +++ /dev/null @@ -1,381 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { IChatQuota, IChatQuotaService } from '../../../platform/chat/common/chatQuotaService'; -import { Disposable } from '../../../util/vs/base/common/lifecycle'; - -const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; -const THRESHOLDS = [50, 75, 90, 95]; - -interface IRateLimitWarning { - percentUsed: number; - type: 'session' | 'weekly'; - resetDate: Date; -} - -interface IQuotaWarning { - percentUsed: number; - resetDate: Date; -} - -/** - * Manages a single chat input notification for quota and rate limit status. - * - * Listens to `IChatQuotaService.onDidChange` and determines whether a - * new threshold has been crossed, then shows the highest-priority notification: - * - * 1. **Quota exhausted** — info, not auto-dismissed, only dismissible via X. - * 2. **Quota approaching** — info, auto-dismissed on next message. - * 3. **Rate-limit warning** — info, auto-dismissed on next message. - */ -export class ChatInputNotificationContribution extends Disposable { - - private _notification: vscode.ChatInputNotification | undefined; - /** Tracks whether the current notification is the quota-exhausted variant. */ - private _showingExhausted = false; - /** Whether a copilot token was present on the last {@link _update} call. */ - private _hadCopilotToken = false; - - /** - * Previous percent-used values for threshold crossing detection. - * `undefined` means no data has been seen yet — the first value - * establishes a baseline without triggering a notification. - */ - private _prevQuotaPercentUsed: number | undefined; - private _prevSessionPercentUsed: number | undefined; - private _prevWeeklyPercentUsed: number | undefined; - private _prevAdditionalUsageEnabled: boolean | undefined; - - private get _quotaUsedUp(): boolean { - const info = this._chatQuotaService.quotaInfo; - if (!info) { - return false; - } - if (info.unlimited) { - return !info.hasQuota; - } - return info.percentRemaining <= 0; - } - - constructor( - @IAuthenticationService private readonly _authService: IAuthenticationService, - @IChatQuotaService private readonly _chatQuotaService: IChatQuotaService, - ) { - super(); - this._register(this._authService.onDidAuthenticationChange(() => this._update())); - this._register(this._chatQuotaService.onDidChange(() => this._update())); - } - - /** - * Single entry point that determines the highest-priority notification - * to show (or whether to hide). - */ - private _update(): void { - const hasCopilotToken = !!this._authService.copilotToken; - const wasSignedIn = this._hadCopilotToken; - this._hadCopilotToken = hasCopilotToken; - - // Detect signed-in → signed-out transition: clear state and hide. - if (wasSignedIn && !hasCopilotToken) { - this._prevQuotaPercentUsed = undefined; - this._prevSessionPercentUsed = undefined; - this._prevWeeklyPercentUsed = undefined; - this._prevAdditionalUsageEnabled = undefined; - this._hideNotification(); - this._showingExhausted = false; - return; - } - - // Skip quota notifications for PRU users — only show for UBB. - const isQuotaNotificationEligible = !hasCopilotToken - || !!this._authService.copilotToken?.isUsageBasedBilling; - - // Priority 1: Quota exhausted or fully used — sticky info notification - if (isQuotaNotificationEligible && this._quotaUsedUp) { - const additionalUsageEnabled = this._chatQuotaService.additionalUsageEnabled; - const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; - this._prevAdditionalUsageEnabled = additionalUsageEnabled; - - if (additionalUsageEnabled) { - // Show overage notification on a live transition to 100%, - // or when overages are enabled while already at 100%. - if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { - this._showOverageActivationNotification(); - } - } else { - this._showExhaustedNotification(); - } - return; - } - - // Priority 2: Quota approaching threshold - if (isQuotaNotificationEligible) { - const quotaWarning = this._computeQuotaWarning(); - if (quotaWarning) { - this._fetchAndShowQuotaWarning(quotaWarning); - return; - } - } - - // Priority 3: Rate-limit warning (session > weekly) - const rateLimitWarning = this._computeRateLimitWarning(); - if (rateLimitWarning) { - this._showRateLimitWarning(rateLimitWarning); - return; - } - - // Nothing new to show — only hide if the exhausted notification is - // active and the quota is no longer exhausted (state-driven). - if (this._showingExhausted && !this._quotaUsedUp) { - this._hideNotification(); - } - } - - // --- Fetch and show quota warning ---------------------------------------- - - /** - * Fetches up-to-date quota data before showing a threshold notification, - * ensuring the displayed percentage reflects the latest server state. - */ - private async _fetchAndShowQuotaWarning(fallbackWarning: IQuotaWarning): Promise { - try { - await this._chatQuotaService.refreshQuota(); - // After the async refresh, quota may have become exhausted or - // fully used (a re-entrant _update() from onDidChange may have - // already shown the exhausted notification). - if (this._quotaUsedUp) { - return; - } - - const freshInfo = this._chatQuotaService.quotaInfo; - if (freshInfo && !freshInfo.unlimited) { - this._showQuotaApproachingWarning({ - percentUsed: Math.floor(100 - freshInfo.percentRemaining), - resetDate: freshInfo.resetDate, - }); - } else { - this._showQuotaApproachingWarning(fallbackWarning); - } - } catch { - this._showQuotaApproachingWarning(fallbackWarning); - } - } - - // --- Threshold crossing detection ---------------------------------------- - - private _computeQuotaWarning(): IQuotaWarning | undefined { - const info = this._chatQuotaService.quotaInfo; - if (!info || info.unlimited) { - this._prevQuotaPercentUsed = undefined; - return undefined; - } - const percentUsed = 100 - info.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); - this._prevQuotaPercentUsed = percentUsed; - if (crossed !== undefined) { - return { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate }; - } - return undefined; - } - - private _computeRateLimitWarning(): IRateLimitWarning | undefined { - const { session, weekly } = this._chatQuotaService.rateLimitInfo; - - // Always update both prev values so neither becomes stale. - const sessionWarning = this._checkCrossing(session, this._prevSessionPercentUsed); - this._prevSessionPercentUsed = sessionWarning.newPrev; - - const weeklyWarning = this._checkCrossing(weekly, this._prevWeeklyPercentUsed); - this._prevWeeklyPercentUsed = weeklyWarning.newPrev; - - if (sessionWarning.warning) { - return { ...sessionWarning.warning, type: 'session' }; - } - if (weeklyWarning.warning) { - return { ...weeklyWarning.warning, type: 'weekly' }; - } - return undefined; - } - - private _checkCrossing( - info: IChatQuota | undefined, - prevPercentUsed: number | undefined, - ): { newPrev: number | undefined; warning?: { percentUsed: number; resetDate: Date } } { - if (!info || info.unlimited) { - return { newPrev: undefined }; - } - const percentUsed = 100 - info.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); - return { - newPrev: percentUsed, - warning: crossed !== undefined - ? { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate } - : undefined, - }; - } - - /** - * Returns the highest threshold that was newly crossed, or `undefined`. - * A threshold is "crossed" when the previous value was below it and the - * current value is at or above it. When `previous` is `undefined` (first - * data arrival), no crossing is detected — only the baseline is stored. - */ - private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { - if (previous === undefined) { - return undefined; - } - for (let i = THRESHOLDS.length - 1; i >= 0; i--) { - const threshold = THRESHOLDS[i]; - if (previous < threshold && current >= threshold) { - return threshold; - } - } - return undefined; - } - - // --- Quota exhausted --------------------------------------------------- - - private _showExhaustedNotification(): void { - const notification = this._ensureNotification(); - this._showingExhausted = true; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credit Limit Reached'); - - const isAnonymous = !!this._authService.copilotToken?.isNoAuthUser; - const isFree = !!this._authService.copilotToken?.isFreeUser; - const isManagedPlan = !!this._authService.copilotToken?.isManagedPlan; - const quotaInfo = this._chatQuotaService.quotaInfo; - const hadOverage = quotaInfo ? quotaInfo.additionalUsageUsed > 0 : false; - - if (isAnonymous) { - notification.description = vscode.l10n.t('Sign in to keep going.'); - notification.actions = [ - { label: vscode.l10n.t('Sign In'), commandId: 'workbench.action.chat.triggerSetup' }, - ]; - } else if (isFree) { - notification.description = vscode.l10n.t('Upgrade to keep going.'); - notification.actions = [ - { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, - ]; - } else if (isManagedPlan) { - notification.description = vscode.l10n.t('Contact your admin to increase your limits.'); - notification.actions = []; - } else if (hadOverage) { - notification.description = vscode.l10n.t('Increase your budget to keep building.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } else { - notification.description = vscode.l10n.t('Manage your budget to keep building.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } - - notification.show(); - } - - // --- Overage notification ----------------------------------------------- - - private _showOverageActivationNotification(): void { - const notification = this._ensureNotification(); - this._showingExhausted = true; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credit Limit Reached'); - notification.description = vscode.l10n.t('Additional budget is now covering extra usage.'); - notification.actions = []; - - notification.show(); - } - - // --- Quota approaching -------------------------------------------------- - - private _showQuotaApproachingWarning(warning: IQuotaWarning): void { - const notification = this._ensureNotification(); - this._showingExhausted = false; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credits at {0}%', warning.percentUsed); - - const isAnonymous = !!this._authService.copilotToken?.isNoAuthUser; - const isFree = !!this._authService.copilotToken?.isFreeUser; - const isManagedPlan = !!this._authService.copilotToken?.isManagedPlan; - - if (isAnonymous || isFree) { - notification.description = vscode.l10n.t('Upgrade to continue past the limit.'); - notification.actions = [ - { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, - ]; - } else if (isManagedPlan) { - notification.description = vscode.l10n.t('Contact your admin to increase your limits.'); - notification.actions = []; - } else if (this._chatQuotaService.additionalUsageEnabled) { - notification.description = vscode.l10n.t('Additional budget is enabled to cover extra usage.'); - notification.actions = []; - } else { - notification.description = vscode.l10n.t('Set additional budget to cover extra usage.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } - - notification.show(); - } - - // --- Rate limit warning ------------------------------------------------- - - private _showRateLimitWarning(warning: IRateLimitWarning): void { - const notification = this._ensureNotification(); - this._showingExhausted = false; - - const dateStr = this._formatResetDate(warning.resetDate); - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - - notification.message = warning.type === 'session' - ? vscode.l10n.t("You've used {0}% of your session rate limit.", warning.percentUsed) - : vscode.l10n.t("You've used {0}% of your weekly rate limit.", warning.percentUsed); - notification.description = vscode.l10n.t('Resets on {0}.', dateStr); - notification.actions = []; - - notification.show(); - } - - // --- Helpers ------------------------------------------------------------ - - private _formatResetDate(resetDate: Date): string { - const now = new Date(); - const includeYear = resetDate.getFullYear() !== now.getFullYear(); - return new Intl.DateTimeFormat(undefined, includeYear - ? { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' } - : { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' } - ).format(resetDate); - } - - private _ensureNotification(): vscode.ChatInputNotification { - if (!this._notification) { - this._notification = vscode.chat.createInputNotification(QUOTA_NOTIFICATION_ID); - this._register({ dispose: () => this._notification?.dispose() }); - } - return this._notification; - } - - private _hideNotification(): void { - if (this._notification) { - this._notification.hide(); - } - } -} diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts deleted file mode 100644 index 034a3ff4e186a..0000000000000 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts +++ /dev/null @@ -1,733 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { Emitter } from '../../../../util/vs/base/common/event'; -import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; -import { IChatQuota, IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService'; - -// ---- vscode mock ----------------------------------------------------------- - -const mockNotification = { - severity: 0, - dismissible: false, - autoDismissOnMessage: false, - message: '', - description: '', - actions: [] as { label: string; commandId: string }[], - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), -}; - -vi.mock('vscode', () => ({ - ChatInputNotificationSeverity: { Info: 1 }, - chat: { - createInputNotification: vi.fn(() => mockNotification), - }, - l10n: { t: (str: string, ...args: unknown[]) => str.replace(/\{(\d+)\}/g, (_, i) => String(args[Number(i)])) }, -})); - -import { ChatInputNotificationContribution } from '../chatInputNotification.contribution'; - -// ---- helpers --------------------------------------------------------------- - -function createAuthService(opts?: { anyGitHubSession?: unknown; copilotToken?: unknown }) { - const emitter = new Emitter(); - const hasSession = opts && 'anyGitHubSession' in opts; - const hasToken = opts && 'copilotToken' in opts; - const authService = { - _serviceBrand: undefined, - anyGitHubSession: hasSession ? opts.anyGitHubSession : { accessToken: 'tok' }, - copilotToken: hasToken ? opts.copilotToken : { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }, - onDidAuthenticationChange: emitter.event, - } as unknown as IAuthenticationService; - return { authService, emitter }; -} - -function makeQuota(percentRemaining: number, opts?: Partial): IChatQuota { - return { - quota: 100, - percentRemaining, - unlimited: false, - hasQuota: true, - additionalUsageUsed: 0, - additionalUsageEnabled: false, - resetDate: new Date('2026-06-01T00:00:00Z'), - ...opts, - }; -} - -function createQuotaService(opts?: { - quotaExhausted?: boolean; - quotaInfo?: IChatQuota; - session?: IChatQuota; - weekly?: IChatQuota; - additionalUsageEnabled?: boolean; -}) { - const emitter = new Emitter(); - const quotaService = { - _serviceBrand: undefined, - onDidChange: emitter.event, - quotaExhausted: opts?.quotaExhausted ?? false, - quotaInfo: opts?.quotaInfo, - rateLimitInfo: { session: opts?.session, weekly: opts?.weekly }, - additionalUsageEnabled: opts?.additionalUsageEnabled ?? false, - getCreditsForTurn: () => undefined, - processQuotaHeaders: vi.fn(), - processQuotaSnapshots: vi.fn(), - setLastCopilotUsage: vi.fn(), - resetTurnCredits: vi.fn(), - clearQuota: vi.fn(), - refreshQuota: vi.fn().mockResolvedValue(undefined), - } as unknown as IChatQuotaService; - return { quotaService, emitter }; -} - -// ---- tests ----------------------------------------------------------------- - -describe('ChatInputNotificationContribution', () => { - let authEmitter: Emitter; - let authService: IAuthenticationService; - let quotaEmitter: Emitter; - let quotaService: IChatQuotaService; - let contribution: ChatInputNotificationContribution; - - function setup(authOpts?: Parameters[0], quotaOpts?: Parameters[0]) { - const auth = createAuthService(authOpts); - const quota = createQuotaService(quotaOpts); - authEmitter = auth.emitter; - authService = auth.authService; - quotaEmitter = quota.emitter; - quotaService = quota.quotaService; - contribution = new ChatInputNotificationContribution(authService, quotaService); - } - - beforeEach(() => { - vi.clearAllMocks(); - mockNotification.show.mockClear(); - mockNotification.hide.mockClear(); - mockNotification.message = ''; - mockNotification.description = ''; - mockNotification.actions = []; - }); - - afterEach(() => { - contribution?.dispose(); - }); - - // --- sign-out behaviour -------------------------------------------------- - - describe('sign-out clears state and hides notification', () => { - test('hides notification when copilot token disappears (sign out)', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - // Trigger _update with exhausted quota → shows notification - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - // User signs out — copilot token cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - - test('shows newly crossed threshold after sign-out + sign-in', async () => { - setup({}, { quotaInfo: makeQuota(60) }); // 40% used — baseline - - // Establish baseline - quotaEmitter.fire(); - - // Cross 50% threshold → notification shown - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - mockNotification.show.mockClear(); - - // Sign out → prev values cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in — quota still at 50% → baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage increases past 75% → new threshold fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('sign-out resets showingExhausted flag', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - mockNotification.show.mockClear(); - - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in, quota no longer exhausted - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = undefined; - (quotaService as any).rateLimitInfo = { session: undefined, weekly: undefined }; - authEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('sign-out while no notification was active is harmless', () => { - setup(); - - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - expect(mockNotification.hide).not.toHaveBeenCalled(); - }); - - test('anonymous UBB user with no GitHub session still sees quota notifications', () => { - setup( - { anyGitHubSession: undefined, copilotToken: { isNoAuthUser: true, isFreeUser: false, isUsageBasedBilling: true } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).toBe('Sign in to keep going.'); - }); - - test('anonymous PRU user does not see quota notifications', () => { - setup( - { anyGitHubSession: undefined, copilotToken: { isNoAuthUser: true, isFreeUser: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- threshold crossing (window reload / sign-in) ------------------------ - - describe('threshold crossing on reload and sign-in', () => { - test('first data arrival stores baseline without notification', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(25) }, // 75% used — already above 50% and 75% - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('notifies when crossing a new threshold after baseline', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(40) }, // 60% used — baseline - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('first rate limit data stores baseline without notification', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { session: makeQuota(10) }, // 90% session used - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('notifies when crossing a threshold from below', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — below all thresholds - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 50%'); - }); - - test('sign-out clears baseline so next sign-in re-establishes it', () => { - setup( - {}, - { quotaInfo: makeQuota(25) }, // 75% used - ); - - // Establish baseline - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out → prev values cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in — first data stores new baseline, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('late sign-in stores baseline then fires on new crossing', async () => { - setup({ copilotToken: undefined }, {}); - - // Sign in — quota data arrives at 60% - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(40); // 60% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% → notification fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('not signed in → 0% → sign out → 60% does not fire 50% threshold', async () => { - setup({ copilotToken: undefined }, {}); - - // Sign in at 0% - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(100); // 0% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out → prev cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign in at 60% — baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(40); // 60% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% → notification fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('sign-out + sign-in at higher level does not fire stale crossing', () => { - setup( - {}, - { quotaInfo: makeQuota(60) }, // 40% used - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign into different account at 75% — baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(25); // 75% - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- basic notification lifecycle ---------------------------------------- - - describe('quota exhausted', () => { - test('shows exhausted notification', () => { - setup( - { copilotToken: { isFreeUser: true, isNoAuthUser: false, isUsageBasedBilling: true } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('hides exhausted when quota is no longer exhausted', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - }); - - describe('quota approaching threshold', () => { - test('shows warning when crossing 50% threshold', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 50%'); - }); - - test('does not re-show the same threshold', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - - mockNotification.show.mockClear(); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('shows higher threshold when usage increases', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - - mockNotification.show.mockClear(); - (quotaService as any).quotaInfo = makeQuota(10); // 90% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 90%'); - }); - }); - - describe('rate limit warning', () => { - test('shows session rate limit warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { session: makeQuota(60) }, // 40% session used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('75%'); - expect(mockNotification.message).toContain('session'); - }); - - test('shows weekly rate limit warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { weekly: makeQuota(60) }, // 40% weekly used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: undefined, weekly: makeQuota(10) }; // 90% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('90%'); - expect(mockNotification.message).toContain('weekly'); - }); - }); - - describe('priority ordering', () => { - test('exhausted takes priority over threshold warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('threshold warning takes priority over rate limit', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60), session: makeQuota(60) }, // 40% used — baselines - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(10); // 90% quota used - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% session used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.message).toBe('Credits at 90%'); - }); - }); - - describe('never-signed-in user still gets notifications', () => { - test('shows exhausted notification even with no copilot token initially', () => { - setup( - { copilotToken: undefined }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - }); - - describe('PRU users do not see quota notifications', () => { - test('does not show exhausted notification for individual PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show exhausted notification for free PRU user', () => { - setup( - { copilotToken: { isFreeUser: true, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show exhausted notification for managed plan PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: true, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show approaching notification for PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(5) }, // 95% used - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('still shows rate limit warning for PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { session: makeQuota(60) }, // 40% session used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('session'); - }); - }); - - // --- quota used up (percentRemaining <= 0) -------------------------------- - - describe('quota fully used (percentRemaining <= 0)', () => { - test('shows exhausted notification when percentRemaining hits 0', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('hides exhausted notification when percentRemaining recovers above 0', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - - test('does not show exhausted for unlimited quota at 0 percentRemaining', () => { - setup( - {}, - { quotaInfo: makeQuota(0, { unlimited: true, hasQuota: true }) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- overage activation notification ------------------------------------ - - describe('overage activation notification', () => { - test('shows overage notification on live transition to 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(10), additionalUsageEnabled: true }, // 90% used — baseline - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Cross to 100% - (quotaService as any).quotaInfo = makeQuota(0); - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).toBe('Additional budget is now covering extra usage.'); - }); - - test('does not show overage notification on reload when already at 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: true }, - ); - - // First data arrival at 100% — baseline, no notification - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('shows standard exhausted notification on reload at 100% without overages', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: false }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).not.toBe('Additional budget is now covering extra usage.'); - }); - - test('shows overage notification when overages are enabled while already at 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: false }, - ); - - // First update: exhausted without overages - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - mockNotification.show.mockClear(); - - // User enables overages in settings — next API response updates state - (quotaService as any).additionalUsageEnabled = true; - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.description).toBe('Additional budget is now covering extra usage.'); - }); - }); - - // --- _fetchAndShowQuotaWarning race guard -------------------------------- - - describe('fetchAndShowQuotaWarning race guard', () => { - test('does not overwrite exhausted notification after refreshQuota', async () => { - setup( - {}, - { quotaInfo: makeQuota(10) }, // 90% used — baseline - ); - - quotaEmitter.fire(); - - // refreshQuota will make quota exhausted during the await - (quotaService as any).refreshQuota = vi.fn(async () => { - (quotaService as any).quotaInfo = makeQuota(0); - // Simulate the re-entrant _update from onDidChange - quotaEmitter.fire(); - }); - - // Cross 95% → triggers _fetchAndShowQuotaWarning - (quotaService as any).quotaInfo = makeQuota(5); - quotaEmitter.fire(); - await Promise.resolve(); - - // The exhausted notification from the re-entrant _update should win - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - }); -}); diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 0c4579083e3c2..57d18656c6bbf 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -18,7 +18,6 @@ import { CompletionsUnificationContribution } from '../../completions/vscode-nod import { ConfigurationMigrationContribution } from '../../configuration/vscode-node/configurationMigration'; import { ContextKeysContribution } from '../../contextKeys/vscode-node/contextKeys.contribution'; import { ByokUtilityModelNotificationContribution } from '../../chatInputNotification/vscode-node/byokUtilityModel.contribution'; -import { ChatInputNotificationContribution } from '../../chatInputNotification/vscode-node/chatInputNotification.contribution'; import { AiMappedEditsContrib } from '../../conversation/vscode-node/aiMappedEditsContrib'; import { ConversationFeature } from '../../conversation/vscode-node/conversationFeature'; import { FeedbackCommandContribution } from '../../conversation/vscode-node/feedbackContribution'; @@ -76,7 +75,6 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(FetcherTelemetryContribution), asContributionFactory(PowerStateLogger), asContributionFactory(ContextKeysContribution), - asContributionFactory(ChatInputNotificationContribution), asContributionFactory(ByokUtilityModelNotificationContribution), asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index ed3801b5d132e..de5209d8e6e33 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -113,7 +113,6 @@ import { ConversationStore, IConversationStore } from '../../conversationStore/n import { SimilarFilesContextService } from '../../inlineEdits/vscode-node/similarFilesContext'; import { IIntentService, IntentService } from '../../intents/node/intentService'; import { INewWorkspacePreviewContentManager, NewWorkspacePreviewContentManagerImpl } from '../../intents/node/newIntent'; -import { ITestGenInfoStorage, TestGenInfoStorage } from '../../intents/node/testIntent/testInfoStorage'; import { LanguageContextProviderService } from '../../languageContextProvider/vscode-node/languageContextProviderService'; import { ILinkifyService, LinkifyService } from '../../linkify/common/linkifyService'; import { DebugCommandToConfigConverter, IDebugCommandToConfigConverter } from '../../onboardDebug/node/commandToConfigConverter'; @@ -212,7 +211,6 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IGithubCodeSearchService, new SyncDescriptor(GithubCodeSearchService)); builder.define(IGithubAvailableEmbeddingTypesService, new SyncDescriptor(GithubAvailableEmbeddingTypesService)); - builder.define(ITestGenInfoStorage, new SyncDescriptor(TestGenInfoStorage)); // Used for test generation (/tests intent) builder.define(IParserService, new SyncDescriptor(ParserServiceImpl, [/*useWorker*/ true])); builder.define(IIntentService, new SyncDescriptor(IntentService)); builder.define(INaiveChunkingService, new SyncDescriptor(NaiveChunkingService)); diff --git a/extensions/copilot/src/extension/intents/node/testIntent/testFromTestInvocation.tsx b/extensions/copilot/src/extension/intents/node/testIntent/testFromTestInvocation.tsx index 57170b74510c1..0950a96933327 100644 --- a/extensions/copilot/src/extension/intents/node/testIntent/testFromTestInvocation.tsx +++ b/extensions/copilot/src/extension/intents/node/testIntent/testFromTestInvocation.tsx @@ -9,7 +9,6 @@ import { IResponsePart } from '../../../../platform/chat/common/chatMLFetcher'; import { ChatLocation } from '../../../../platform/chat/common/commonTypes'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IParserService } from '../../../../platform/parser/node/parserService'; -import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { isNotebookCellOrNotebookChatInput } from '../../../../util/common/notebooks'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { illegalArgument } from '../../../../util/vs/base/common/errors'; @@ -27,12 +26,9 @@ import { Tag } from '../../../prompts/node/base/tag'; import { ChatToolReferences, ChatVariables, UserQuery } from '../../../prompts/node/panel/chatVariables'; import { HistoryWithInstructions } from '../../../prompts/node/panel/conversationHistory'; import { CustomInstructions } from '../../../prompts/node/panel/customInstructions'; -import { CodeBlock } from '../../../prompts/node/panel/safeElements'; import { SelectionSplitKind, SummarizedDocumentData, SummarizedDocumentWithSelection } from './summarizedDocumentWithSelection'; import { TestDeps } from './testDeps'; -import { ITestGenInfo, ITestGenInfoStorage } from './testInfoStorage'; import { TestsIntent } from './testIntent'; -import { formatRequestAndUserQuery } from './testPromptUtil'; import { PseudoStopStartResponseProcessor } from '../../../prompt/node/pseudoStartStopConversationCallback'; @@ -50,7 +46,6 @@ export class TestFromTestInvocation implements IIntentInvocation { private readonly context: IDocumentContext, private readonly alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, - @ITestGenInfoStorage private readonly testGenInfoStorage: ITestGenInfoStorage, ) { } @@ -61,12 +56,6 @@ export class TestFromTestInvocation implements IIntentInvocation { >, token: vscode.CancellationToken ) { - const testGenInfo = this.testGenInfoStorage.sourceFileToTest; - - if (testGenInfo !== undefined) { - this.testGenInfoStorage.sourceFileToTest = undefined; - } - const renderer = PromptRenderer.create( this.instantiationService, this.endpoint, @@ -75,7 +64,6 @@ export class TestFromTestInvocation implements IIntentInvocation { context: this.context, promptContext, alreadyConsumedChatVariable: this.alreadyConsumedChatVariable, - testGenInfo, } ); @@ -114,14 +102,12 @@ type Props = PromptElementProps<{ context: IDocumentContext; promptContext: IBuildPromptContext; alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined; - testGenInfo: ITestGenInfo | undefined; }>; class TestFromTestPrompt extends PromptElement { constructor( props: Props, - @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IParserService private readonly parserService: IParserService ) { super(props); @@ -130,32 +116,13 @@ class TestFromTestPrompt extends PromptElement { override async render(_state: void, sizing: PromptSizing) { const { history, query, chatVariables, } = this.props.promptContext; - const { context, testGenInfo, alreadyConsumedChatVariable, } = this.props; + const { context, alreadyConsumedChatVariable, } = this.props; if (isNotebookCellOrNotebookChatInput(context.document.uri)) { throw illegalArgument('TestFromTestPrompt should not be used for notebooks'); } - const testedSymbolIdentifier = testGenInfo?.identifier; - - const requestAndUserQuery = testGenInfo === undefined - ? `Please, generate more tests, taking into account existing tests. ${query}`.trim() - : formatRequestAndUserQuery({ - workspaceService: this.workspaceService, - chatVariables, - userQuery: query, - testFileToWriteTo: context.document.uri, - testedSymbolIdentifier, - context, - }); - - let testedDeclarationExcerpt = undefined; - if (testGenInfo !== undefined) { - const srcFileDoc = await this.workspaceService.openTextDocument(testGenInfo.uri); - const declStart = testGenInfo.target.start; - const expandedRange = testGenInfo.target.with(declStart.with(declStart.line, 0)); - testedDeclarationExcerpt = srcFileDoc.getText(expandedRange); - } + const requestAndUserQuery = `Please, generate more tests, taking into account existing tests. ${query}`.trim(); const data = await SummarizedDocumentData.create( this.parserService, @@ -190,7 +157,7 @@ class TestFromTestPrompt extends PromptElement { {/* include summarized source file: */} - + {/* include summarized test file: */} { tokenBudget={sizing.tokenBudget / 3} _allowEmptySelection={true} />{ /* FIXME@ulugbekna: rework summarization to be more intelligent */} - {/* repeat tested declaration -- otherwise, model seems to forget it: */} - {testGenInfo !== undefined && testedDeclarationExcerpt !== undefined && /* FIXME@ulugbekna: include class around */ - - {`Repeating excerpt from \`${testGenInfo?.uri.path}\` here that needs to be tested:`}{/* FIXME@ulugbekna */}
- -
} diff --git a/extensions/copilot/src/extension/intents/node/testIntent/testInfoStorage.ts b/extensions/copilot/src/extension/intents/node/testIntent/testInfoStorage.ts deleted file mode 100644 index e6a63e8357a1e..0000000000000 --- a/extensions/copilot/src/extension/intents/node/testIntent/testInfoStorage.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createServiceIdentifier } from '../../../../util/common/services'; -import { URI } from '../../../../util/vs/base/common/uri'; -import { Range } from '../../../../vscodeTypes'; - - -export const ITestGenInfoStorage = createServiceIdentifier('ITestGenInfoStorage'); - -export interface ITestGenInfo { - uri: URI; - target: Range; - identifier: string | undefined; -} - -/** - * Global storage for test generation information that allows data flow between - * test gen entry points such as (context menu item and code action) and an inline chat that - * are created from those entry points. - */ -export interface ITestGenInfoStorage { - readonly _serviceBrand: undefined; - - sourceFileToTest: ITestGenInfo | undefined; -} - -export class TestGenInfoStorage implements ITestGenInfoStorage { - declare _serviceBrand: undefined; - - sourceFileToTest = undefined; -} diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 4b791323c21f9..229578140cf4b 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -480,7 +480,7 @@ export abstract class ToolCallingLoop(); + private readonly hookErrors = new Map(); + + readonly hookCalls: Array<{ hookType: ChatHookType; input: unknown }> = []; + + logConfiguredHooks(): void { } + + setHookResults(hookType: ChatHookType, results: ChatHookResult[]): void { + this.hookResults.set(hookType, results); + } + + setHookError(hookType: ChatHookType, error: Error): void { + this.hookErrors.set(hookType, error); + } + + clearCalls(): void { + this.hookCalls.length = 0; + } + + getCallsForHook(hookType: ChatHookType): Array<{ hookType: ChatHookType; input: unknown }> { + return this.hookCalls.filter(call => call.hookType === hookType); + } + + async executeHook(hookType: ChatHookType, _hooks: unknown, input: unknown, _sessionId?: string, _token?: CancellationToken): Promise { + this.hookCalls.push({ hookType, input }); + + const error = this.hookErrors.get(hookType); + if (error) { + throw error; + } + + return this.hookResults.get(hookType) || []; + } + + async executePreToolUseHook(): Promise { + return undefined; + } + + async executePostToolUseHook(): Promise { + return undefined; + } +} diff --git a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts index 0198db0b9f2d0..fe8ec0913e300 100644 --- a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts +++ b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts @@ -18,7 +18,7 @@ import { createExtensionUnitTestingServices } from '../../../test/node/services' import { IToolsService } from '../../../tools/common/toolsService'; import { TestToolsService } from '../../../tools/node/test/testToolsService'; import { IToolCallingLoopOptions, IToolCallSingleResult, ToolCallingLoop } from '../../node/toolCallingLoop'; -import { MockChatHookService } from './toolCallingLoopHooks.spec'; +import { MockChatHookService } from './mockChatHookService'; /** * Concrete test implementation that exposes autopilot-related protected methods. diff --git a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts index b48e339fa6768..0cb279c67c3d6 100644 --- a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts +++ b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CancellationToken, ChatHookResult, ChatHookType, ChatRequest, LanguageModelToolInformation } from 'vscode'; +import type { CancellationToken, ChatRequest, LanguageModelToolInformation } from 'vscode'; import { IChatHookService, SessionStartHookInput, StopHookInput, SubagentStartHookInput, SubagentStopHookInput } from '../../../../platform/chat/common/chatHookService'; +import { MockChatHookService } from './mockChatHookService'; import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService'; import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig'; import { IOTelService } from '../../../../platform/otel/common/otelService'; @@ -19,79 +20,6 @@ import { IBuildPromptResult, nullRenderPromptResult } from '../../../prompt/node import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { IToolCallingLoopOptions, ToolCallingLoop } from '../../node/toolCallingLoop'; -/** - * Configurable mock implementation of IChatHookService for testing. - * - * Allows tests to configure: - * - Hook results to return for specific hook types - * - Error behavior to simulate hook failures - * - Call tracking to verify hook invocations - */ -export class MockChatHookService implements IChatHookService { - declare readonly _serviceBrand: undefined; - - /** Configured results to return per hook type */ - private readonly hookResults = new Map(); - - /** Configured errors to throw per hook type */ - private readonly hookErrors = new Map(); - - /** Tracks all hook calls for verification */ - readonly hookCalls: Array<{ hookType: ChatHookType; input: unknown }> = []; - - logConfiguredHooks(): void { } - - /** - * Configure the results that should be returned when a specific hook type is executed. - */ - setHookResults(hookType: ChatHookType, results: ChatHookResult[]): void { - this.hookResults.set(hookType, results); - } - - /** - * Configure an error to throw when a specific hook type is executed. - */ - setHookError(hookType: ChatHookType, error: Error): void { - this.hookErrors.set(hookType, error); - } - - /** - * Clear all hook calls for test isolation. - */ - clearCalls(): void { - this.hookCalls.length = 0; - } - - /** - * Get all calls for a specific hook type. - */ - getCallsForHook(hookType: ChatHookType): Array<{ hookType: ChatHookType; input: unknown }> { - return this.hookCalls.filter(call => call.hookType === hookType); - } - - async executeHook(hookType: ChatHookType, _hooks: unknown, input: unknown, _sessionId?: string, _token?: CancellationToken): Promise { - // Track the call - this.hookCalls.push({ hookType, input }); - - // Check if we should throw an error - const error = this.hookErrors.get(hookType); - if (error) { - throw error; - } - - // Return configured results or empty array - return this.hookResults.get(hookType) || []; - } - - async executePreToolUseHook(): Promise { - return undefined; - } - - async executePostToolUseHook(): Promise { - return undefined; - } -} - /** * Minimal concrete implementation of ToolCallingLoop for testing. * Exposes the abstract base class methods for testing while providing diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index dca451ee09325..1c17b756a905e 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -2024,6 +2024,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { return { type: ChatFetchResponseType.RateLimited, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, rateLimitKey: (response.data?.rateLimitKey || ''), isAuto, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.QuotaExceeded) { + // Refresh quota state so the ext→core sync picks up the exhaustion + this._chatQuotaService.refreshQuota(); return { type: ChatFetchResponseType.QuotaExceeded, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.OffTopic) { @@ -2217,6 +2219,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { return { type: ChatFetchResponseType.RateLimited, reason: message, requestId, serverRequestId, retryAfter: undefined, rateLimitKey: '', isAuto, capiError }; } if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured') { + // Refresh quota state so the ext→core sync picks up the exhaustion + this._chatQuotaService.refreshQuota(); return { type: ChatFetchResponseType.QuotaExceeded, reason: message, requestId, serverRequestId, capiError, retryAfter: undefined }; } if (code === 'content_filter') { diff --git a/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts index 2100f66f1a42f..8d1c4a6471d8c 100644 --- a/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/searchSubagentToolCallingLoop.ts @@ -7,8 +7,7 @@ import { randomUUID } from 'crypto'; import type { CancellationToken, ChatRequest, ChatResponseStream, LanguageModelToolInformation, Progress } from 'vscode'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; import { IChatHookService } from '../../../platform/chat/common/chatHookService'; -import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; -import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; +import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes';import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; import { ProxyAgenticEndpoint } from '../../../platform/endpoint/node/proxyAgenticEndpoint'; @@ -51,6 +50,14 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop, token: CancellationToken): Promise { + this._lastBuildPromptContext = buildPromptContext; + return this._renderPrompt(buildPromptContext, progress, token); + } + + private async _renderPrompt(buildPromptContext: IBuildPromptContext, progress: Progress, token: CancellationToken): Promise { const endpoint = await this.getEndpoint(); - // Use the effective tool call limit from options (already adjusted for thoroughness in the tool) const maxSearchTurns = this.options.toolCallLimit; + + const tools = buildPromptContext.tools?.availableTools; + const toolTokens = tools?.length ? await endpoint.acquireTokenizer().countToolTokens(tools) : 0; + + const factor = this._didRetryAfterOverflow + ? SearchSubagentToolCallingLoop.RETRY_SAFETY_FACTOR + : SearchSubagentToolCallingLoop.INITIAL_SAFETY_FACTOR; + const messageBudget = Math.max(1, Math.floor((endpoint.modelMaxPromptTokens - toolTokens) * factor)); + const renderEndpoint = toolTokens > 0 || this._didRetryAfterOverflow ? endpoint.cloneWithTokenOverride(messageBudget) : endpoint; const renderer = PromptRenderer.create( this.instantiationService, - endpoint, + renderEndpoint, SearchSubagentPrompt, { promptContext: buildPromptContext, @@ -129,7 +149,7 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop { @@ -151,33 +171,105 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop { const endpoint = await this.getEndpoint(); - return endpoint.makeChatRequest2({ - debugName: SearchSubagentToolCallingLoop.ID, - messages, - finishedCb, - location: this.options.location, - modelCapabilities: { ...modelCapabilities, reasoningEffort: undefined }, - requestOptions: { - ...requestOptions, - temperature: 0 - }, - // This loop is inside a tool called from another request, so never user initiated - userInitiatedRequest: false, - turnId: this.options.request.id, - topLevelTurnId: this.options.topLevelTurnId, - telemetryProperties: { - requestId: this.options.subAgentInvocationId, - messageId: randomUUID(), - messageSource: 'chat.editAgent', - subType: 'search_subagent', - conversationId: this.options.conversation.sessionId, - parentToolCallId: this.options.parentToolCallId, - parentRequestId: this.options.request.id, - parentHeaderRequestId: this.options.parentHeaderRequestId, - parentModelCallId: this.options.parentModelCallId, - iterationNumber: iterationNumber.toString(), - }, - interactionTypeOverride: 'conversation-subagent' - }, token); + let currentMessages = messages; + while (true) { + const response = await endpoint.makeChatRequest2({ + debugName: SearchSubagentToolCallingLoop.ID, + messages: currentMessages, + finishedCb, + location: this.options.location, + modelCapabilities: { ...modelCapabilities, reasoningEffort: undefined }, + requestOptions: { + ...requestOptions, + temperature: 0 + }, + // This loop is inside a tool called from another request, so never user initiated + userInitiatedRequest: false, + turnId: this.options.request.id, + topLevelTurnId: this.options.topLevelTurnId, + telemetryProperties: { + requestId: this.options.subAgentInvocationId, + messageId: randomUUID(), + messageSource: 'chat.editAgent', + subType: 'search_subagent', + conversationId: this.options.conversation.sessionId, + parentToolCallId: this.options.parentToolCallId, + parentRequestId: this.options.request.id, + parentHeaderRequestId: this.options.parentHeaderRequestId, + parentModelCallId: this.options.parentModelCallId, + iterationNumber: iterationNumber.toString(), + }, + interactionTypeOverride: 'conversation-subagent' + }, token); + + if ( + token.isCancellationRequested || + !this._lastBuildPromptContext || + !isContextOverflowBadRequest(response) + ) { + return response; + } + + if (this._didRetryAfterOverflow) { + this._sendContextOverflowTelemetry('exhausted', endpoint.model, SearchSubagentToolCallingLoop.RETRY_SAFETY_FACTOR); + return response; + } + + this._didRetryAfterOverflow = true; + this._logService.warn(`[searchSubagent] context_length_exceeded from API; re-rendering once at safety factor ${SearchSubagentToolCallingLoop.RETRY_SAFETY_FACTOR}`); + this._sendContextOverflowTelemetry('retried', endpoint.model, SearchSubagentToolCallingLoop.RETRY_SAFETY_FACTOR); + const rerendered = await this.buildPrompt(this._lastBuildPromptContext, { report: () => { } }, token); + currentMessages = rerendered.messages; + } + } + + /** + * Skip the autopilot auto-retry layer for context-overflow BadRequest. + */ + protected override shouldAutoRetry(response: ChatResponse): boolean { + if (isContextOverflowBadRequest(response)) { + return false; + } + return super.shouldAutoRetry(response); + } + + private _sendContextOverflowTelemetry( + outcome: 'retried' | 'exhausted', + model: string, + safetyFactor: number, + ): void { + /* __GDPR__ + "searchSubagent.contextOverflow" : { + "owner": "t-guomaggie", + "comment": "Tracks when the search subagent's model returns a 400 with a context-overflow reason, and whether the single shrink-and-retry recovered.", + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "One of: 'retried' (overflowed on the initial 0.9 budget, re-rendering at the retry factor), 'exhausted' (also overflowed on the retry; failure surfaced to the tool wrapper as a benign fallback)." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model id used by the subagent." }, + "safetyFactor": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The message-budget multiplier in effect after the shrink. Currently always RETRY_SAFETY_FACTOR, but logged as a value so tuning is visible if we change it." } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('searchSubagent.contextOverflow', { + outcome, + model, + }, { + safetyFactor, + }); + } +} + +const CONTEXT_OVERFLOW_REASON_PATTERNS = [ + 'context_length_exceeded', + 'context length', + 'context window', + 'maximum context', + 'prompt is too long', + 'request too large', + 'request_too_large', +]; + +export function isContextOverflowBadRequest(response: ChatResponse): boolean { + if (response.type !== ChatFetchResponseType.BadRequest) { + return false; } + const haystack = `${response.reason ?? ''} ${response.reasonDetail ?? ''}`.toLowerCase(); + return CONTEXT_OVERFLOW_REASON_PATTERNS.some(p => haystack.includes(p)); } diff --git a/extensions/copilot/src/extension/prompt/node/test2Impl.tsx b/extensions/copilot/src/extension/prompt/node/test2Impl.tsx index 9883663ae5b77..32a9e31e18dca 100644 --- a/extensions/copilot/src/extension/prompt/node/test2Impl.tsx +++ b/extensions/copilot/src/extension/prompt/node/test2Impl.tsx @@ -5,13 +5,10 @@ import { PromptElement, PromptElementProps, PromptSizing } from '@vscode/prompt-tsx'; import assert from 'assert'; -import type * as vscode from 'vscode'; import { IIgnoreService } from '../../../platform/ignore/common/ignoreService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ITestGenInfo } from '../../intents/node/testIntent/testInfoStorage'; import { Tag } from '../../prompts/node/base/tag'; import { DocumentSummarizer } from '../../prompts/node/inline/summarizedDocument/summarizeDocumentHelpers'; import { CodeBlock } from '../../prompts/node/panel/safeElements'; @@ -24,10 +21,6 @@ type Props = PromptElementProps<{ * Document here is expected to be a test file. */ documentContext: IDocumentContext; - /** - * Src (ie impl) file to include if already known. - */ - srcFile?: ITestGenInfo; }>; /** @@ -46,21 +39,13 @@ export class Test2Impl extends PromptElement { override async render(state: void, sizing: PromptSizing) { - const { documentContext, srcFile, } = this.props; + const { documentContext } = this.props; assert(isTestFile(documentContext.document), 'Test2Impl must be invoked on a test file.'); - let candidateFile: URI | undefined; - let selection: vscode.Range | undefined; - - if (srcFile) { - candidateFile = srcFile.uri; - selection = srcFile.target; - } else { - // @ulugbekna: find file that this test file corresponds to - const finder = this.instaService.createInstance(TestFileFinder); - candidateFile = await finder.findFileForTestFile(documentContext.document, CancellationToken.None); - } + // @ulugbekna: find file that this test file corresponds to + const finder = this.instaService.createInstance(TestFileFinder); + const candidateFile = await finder.findFileForTestFile(documentContext.document, CancellationToken.None); if (candidateFile === undefined || await this.ignoreService.isCopilotIgnored(candidateFile)) { return undefined; @@ -73,7 +58,7 @@ export class Test2Impl extends PromptElement { const summarizedDoc = await docSummarizer.summarizeDocument( doc, documentContext.fileIndentInfo, - selection, + undefined, sizing.tokenBudget, ); diff --git a/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts b/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts new file mode 100644 index 0000000000000..ad3f5d925776b --- /dev/null +++ b/extensions/copilot/src/extension/prompt/test/node/searchSubagentToolCallingLoop.spec.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { CancellationToken, ChatRequest } from 'vscode'; +import { IChatHookService } from '../../../../platform/chat/common/chatHookService'; +import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../../platform/chat/common/commonTypes'; +import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { generateUuid } from '../../../../util/vs/base/common/uuid'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { MockChatHookService } from '../../../intents/test/node/mockChatHookService'; +import { Conversation, Turn } from '../../../prompt/common/conversation'; +import { IBuildPromptContext } from '../../../prompt/common/intents'; +import { nullRenderPromptResult } from '../../../prompt/node/intents'; +import { + ISearchSubagentToolCallingLoopOptions, + SearchSubagentToolCallingLoop, + isContextOverflowBadRequest, +} from '../../../prompt/node/searchSubagentToolCallingLoop'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; + +class TestSearchSubagentToolCallingLoop extends SearchSubagentToolCallingLoop { + public buildPromptCalls = 0; + public makeChatRequestCalls = 0; + public readonly responseQueue: ChatResponse[] = []; + + public readonly fakeEndpoint = { + modelMaxPromptTokens: 100_000, + acquireTokenizer: () => ({ countToolTokens: async () => 0 }), + cloneWithTokenOverride: () => this.fakeEndpoint, + makeChatRequest2: async (): Promise => { + this.makeChatRequestCalls++; + const next = this.responseQueue.shift(); + if (!next) { + throw new Error('responseQueue exhausted'); + } + return next; + }, + }; + + protected override async buildPrompt(buildPromptContext: IBuildPromptContext) { + this.buildPromptCalls++; + (this as any)._lastBuildPromptContext = buildPromptContext; + return nullRenderPromptResult(); + } + + public get didRetryAfterOverflow(): boolean { + return (this as any)._didRetryAfterOverflow; + } + + public primeBuildPromptContext(): void { + (this as any)._lastBuildPromptContext = {} as IBuildPromptContext; + } + + public callFetch(token: CancellationToken): Promise { + return (this as any).fetch( + { + messages: [], + finishedCb: undefined, + requestOptions: {}, + userInitiatedRequest: false, + turnId: 'turn-1', + modelCapabilities: {}, + iterationNumber: 0, + }, + token, + ); + } +} + +function createMockChatRequest(): ChatRequest { + return { + prompt: 'find things', + command: undefined, + references: [], + location: 1, + location2: undefined, + attempt: 0, + enableCommandDetection: false, + isParticipantDetected: false, + toolReferences: [], + toolInvocationToken: {} as ChatRequest['toolInvocationToken'], + model: null!, + tools: new Map(), + id: generateUuid(), + sessionId: generateUuid(), + } as unknown as ChatRequest; +} + +function createTestConversation(): Conversation { + return new Conversation(generateUuid(), [ + new Turn(generateUuid(), { message: 'test message', type: 'user' }), + ]); +} + +function overflowResponse(): ChatResponse { + return { + type: ChatFetchResponseType.BadRequest, + reason: 'context_length_exceeded', + reasonDetail: 'prompt is too long', + requestId: 'req-overflow', + serverRequestId: undefined, + } as ChatResponse; +} + +function badRequest(reason: string): ChatResponse { + return { + type: ChatFetchResponseType.BadRequest, + reason, + reasonDetail: undefined, + requestId: 'req-bad', + serverRequestId: undefined, + } as ChatResponse; +} + +function successResponse(): ChatResponse { + return { + type: ChatFetchResponseType.Success, + value: 'ok', + requestId: 'req-ok', + serverRequestId: undefined, + } as unknown as ChatResponse; +} + +describe('isContextOverflowBadRequest', () => { + it('returns true for BadRequest with context_length_exceeded reason', () => { + expect(isContextOverflowBadRequest(badRequest('context_length_exceeded'))).toBe(true); + }); + + it('matches case-insensitively', () => { + expect(isContextOverflowBadRequest(badRequest('Context_Length_Exceeded'))).toBe(true); + }); + + it('matches when pattern is in reasonDetail', () => { + expect(isContextOverflowBadRequest({ + type: ChatFetchResponseType.BadRequest, + reason: 'invalid_request_error', + reasonDetail: 'This model has a maximum context length of 200000 tokens', + requestId: 'r', + serverRequestId: undefined, + } as ChatResponse)).toBe(true); + }); + + it('matches the "prompt is too long" pattern', () => { + expect(isContextOverflowBadRequest(badRequest('prompt is too long: 250000 > 200000'))).toBe(true); + }); + + it('matches the "request too large" pattern', () => { + expect(isContextOverflowBadRequest(badRequest('Request too large for model'))).toBe(true); + }); + + it('returns false for BadRequest with unrelated reason', () => { + expect(isContextOverflowBadRequest(badRequest('invalid_tool_schema'))).toBe(false); + }); + + it('returns false for non-BadRequest response types', () => { + expect(isContextOverflowBadRequest(successResponse())).toBe(false); + expect(isContextOverflowBadRequest({ + type: ChatFetchResponseType.Length, + reason: 'context_length_exceeded', + requestId: 'r', + serverRequestId: undefined, + } as ChatResponse)).toBe(false); + expect(isContextOverflowBadRequest({ + type: ChatFetchResponseType.RateLimited, + reason: 'r', + requestId: 'r', + serverRequestId: undefined, + } as ChatResponse)).toBe(false); + }); +}); + +describe('SearchSubagentToolCallingLoop.fetch context-overflow retry', () => { + let disposables: DisposableStore; + let instantiationService: IInstantiationService; + let tokenSource: CancellationTokenSource; + + beforeEach(() => { + disposables = new DisposableStore(); + const serviceCollection = disposables.add(createExtensionUnitTestingServices()); + serviceCollection.define(IChatHookService, new MockChatHookService()); + const accessor = serviceCollection.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + tokenSource = new CancellationTokenSource(); + disposables.add(tokenSource); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function createLoop(): TestSearchSubagentToolCallingLoop { + const options: ISearchSubagentToolCallingLoopOptions = { + conversation: createTestConversation(), + toolCallLimit: 10, + request: createMockChatRequest(), + location: ChatLocation.Panel, + promptText: 'find things', + }; + const loop = instantiationService.createInstance(TestSearchSubagentToolCallingLoop, options); + (loop as any).getEndpoint = async () => loop.fakeEndpoint; + loop.primeBuildPromptContext(); + disposables.add(loop); + return loop; + } + + it('returns success immediately when first attempt succeeds', async () => { + const loop = createLoop(); + loop.responseQueue.push(successResponse()); + + const response = await loop.callFetch(tokenSource.token); + + expect(response.type).toBe(ChatFetchResponseType.Success); + expect(loop.makeChatRequestCalls).toBe(1); + expect(loop.buildPromptCalls).toBe(0); + expect(loop.didRetryAfterOverflow).toBe(false); + }); + + it('retries once on context overflow and succeeds with shrunk budget', async () => { + const loop = createLoop(); + loop.responseQueue.push(overflowResponse(), successResponse()); + + const response = await loop.callFetch(tokenSource.token); + + expect(response.type).toBe(ChatFetchResponseType.Success); + expect(loop.makeChatRequestCalls).toBe(2); + expect(loop.buildPromptCalls).toBe(1); + expect(loop.didRetryAfterOverflow).toBe(true); + }); + + it('returns the final BadRequest when the single retry also overflows', async () => { + const loop = createLoop(); + loop.responseQueue.push(overflowResponse(), overflowResponse()); + + const response = await loop.callFetch(tokenSource.token); + + expect(response.type).toBe(ChatFetchResponseType.BadRequest); + expect(loop.makeChatRequestCalls).toBe(2); + expect(loop.buildPromptCalls).toBe(1); + expect(loop.didRetryAfterOverflow).toBe(true); + }); + + it('returns non-overflow BadRequest immediately without retry', async () => { + const loop = createLoop(); + loop.responseQueue.push(badRequest('invalid_tool_schema')); + + const response = await loop.callFetch(tokenSource.token); + + expect(response.type).toBe(ChatFetchResponseType.BadRequest); + expect(loop.makeChatRequestCalls).toBe(1); + expect(loop.buildPromptCalls).toBe(0); + expect(loop.didRetryAfterOverflow).toBe(false); + }); + + it('stops retrying when cancellation is requested', async () => { + const loop = createLoop(); + loop.responseQueue.push(overflowResponse(), successResponse()); + tokenSource.cancel(); + + const response = await loop.callFetch(tokenSource.token); + + expect(response.type).toBe(ChatFetchResponseType.BadRequest); + expect(loop.makeChatRequestCalls).toBe(1); + expect(loop.buildPromptCalls).toBe(0); + }); +}); + +describe('SearchSubagentToolCallingLoop.shouldAutoRetry', () => { + let disposables: DisposableStore; + let instantiationService: IInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + const serviceCollection = disposables.add(createExtensionUnitTestingServices()); + serviceCollection.define(IChatHookService, new MockChatHookService()); + const accessor = serviceCollection.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function createAutopilotLoop(): TestSearchSubagentToolCallingLoop { + const request = createMockChatRequest(); + (request as any).permissionLevel = 'autopilot'; + const options: ISearchSubagentToolCallingLoopOptions = { + conversation: createTestConversation(), + toolCallLimit: 10, + request, + location: ChatLocation.Panel, + promptText: 'find things', + }; + const loop = instantiationService.createInstance(TestSearchSubagentToolCallingLoop, options); + disposables.add(loop); + return loop; + } + + it('does not auto-retry on context-overflow BadRequest in autopilot mode', () => { + const loop = createAutopilotLoop(); + expect((loop as any).shouldAutoRetry(overflowResponse())).toBe(false); + }); + + it('still auto-retries on unrelated BadRequest in autopilot mode', () => { + const loop = createAutopilotLoop(); + expect((loop as any).shouldAutoRetry(badRequest('invalid_tool_schema'))).toBe(true); + }); +}); diff --git a/extensions/copilot/src/extension/test/vscode-node/services.ts b/extensions/copilot/src/extension/test/vscode-node/services.ts index 81985b4765a90..bbf18d8279687 100644 --- a/extensions/copilot/src/extension/test/vscode-node/services.ts +++ b/extensions/copilot/src/extension/test/vscode-node/services.ts @@ -101,7 +101,6 @@ import { ICopilotInlineCompletionItemProviderService, NullCopilotInlineCompletio import { IPromptWorkspaceLabels, PromptWorkspaceLabels } from '../../context/node/resolvers/promptWorkspaceLabels'; import { IUserFeedbackService, UserFeedbackService } from '../../conversation/vscode-node/userActions'; import { ConversationStore, IConversationStore } from '../../conversationStore/node/conversationStore'; -import { ITestGenInfoStorage, TestGenInfoStorage } from '../../intents/node/testIntent/testInfoStorage'; import { ILinkifyService, LinkifyService } from '../../linkify/common/linkifyService'; import { ILaunchConfigService } from '../../onboardDebug/common/launchConfigService'; import { DebugCommandToConfigConverter, IDebugCommandToConfigConverter } from '../../onboardDebug/node/commandToConfigConverter'; @@ -176,7 +175,6 @@ export function createExtensionTestingServices(): TestingServiceCollection { testingServiceCollection.define(ITestProvider, new SyncDescriptor(TestProvider)); testingServiceCollection.define(INaiveChunkingService, new SyncDescriptor(NaiveChunkingService)); testingServiceCollection.define(ILinkifyService, new SyncDescriptor(LinkifyService)); - testingServiceCollection.define(ITestGenInfoStorage, new SyncDescriptor(TestGenInfoStorage)); testingServiceCollection.define(IEditToolLearningService, new SyncDescriptor(EditToolLearningService)); testingServiceCollection.define(IDebugCommandToConfigConverter, new SyncDescriptor(DebugCommandToConfigConverter)); testingServiceCollection.define(ILaunchConfigService, new SyncDescriptor(LaunchConfigService)); diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index f05956cfdafdd..25e87cbe04d54 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; +import { BudgetExceededError } from '@vscode/prompt-tsx/dist/base/materialized'; import * as path from 'path'; import type * as vscode from 'vscode'; import { ChatFetchResponseType } from '../../../platform/chat/common/commonTypes'; @@ -22,7 +23,8 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { ChatResponseNotebookEditPart, ChatResponseTextEditPart, ChatToolInvocationPart, ExtendedLanguageModelToolResult, LanguageModelTextPart, MarkdownString, Range } from '../../../vscodeTypes'; import { Conversation, Turn } from '../../prompt/common/conversation'; import { IBuildPromptContext } from '../../prompt/common/intents'; -import { SearchSubagentToolCallingLoop } from '../../prompt/node/searchSubagentToolCallingLoop'; +import type { IToolCallLoopResult } from '../../intents/node/toolCallingLoop'; +import { SearchSubagentToolCallingLoop, isContextOverflowBadRequest } from '../../prompt/node/searchSubagentToolCallingLoop'; import { ToolName } from '../common/toolNames'; import { CopilotToolMode, ICopilotTool, ICopilotToolCtor, ToolRegistry } from '../common/toolsRegistry'; import { assertFileOkForTool, isFileExternalAndNeedsConfirmation } from './toolUtils'; @@ -49,10 +51,22 @@ const THOROUGHNESS_MULTIPLIERS: Record\nThe search subagent was unable to complete this query because the accumulated search context exceeded the model's context window. Consider issuing a more focused query.\n`; + function computeToolCallLimitForThoroughness(baseLimit: number, thoroughness: NonNullable): number { return Math.max(1, Math.round(baseLimit * THOROUGHNESS_MULTIPLIERS[thoroughness])); } +export function mapLoopResponseToText(result: IToolCallLoopResult): string { + if (result.response.type === ChatFetchResponseType.Success) { + return result.toolCallRounds.at(-1)?.response ?? result.round.response ?? ''; + } + if (isContextOverflowBadRequest(result.response)) { + return CONTEXT_OVERFLOW_FALLBACK; + } + return `The search subagent request failed with this message:\n${result.response.type}: ${result.response.reason}`; +} + class SearchSubagentTool implements ICopilotTool { public static readonly toolName = ToolName.SearchSubagent; public static readonly nonDeferred = true; @@ -156,8 +170,6 @@ class SearchSubagentTool implements ICopilotTool { // Wrap the loop execution in captureInvocation with the new token // All nested tool calls will now be logged under this same CapturingToken - const loopResult = await this.requestLogger.captureInvocation(searchSubagentToken, () => loop.run(stream, token)); - // Build subagent trajectory metadata that will be logged via toolMetadata // All nested tool calls are already logged by ToolCallingLoop.logToolResult() const toolMetadata = { @@ -168,11 +180,15 @@ class SearchSubagentTool implements ICopilotTool { agentName: 'search' }; - let subagentResponse = ''; - if (loopResult.response.type === ChatFetchResponseType.Success) { - subagentResponse = loopResult.toolCallRounds.at(-1)?.response ?? loopResult.round.response ?? ''; - } else { - subagentResponse = `The search subagent request failed with this message:\n${loopResult.response.type}: ${loopResult.response.reason}`; + let subagentResponse: string; + try { + const loopResult = await this.requestLogger.captureInvocation(searchSubagentToken, () => loop.run(stream, token)); + subagentResponse = mapLoopResponseToText(loopResult); + } catch (err) { + if (!(err instanceof BudgetExceededError)) { + throw err; + } + subagentResponse = CONTEXT_OVERFLOW_FALLBACK; } // Parse and hydrate code snippets from tags const hydratedResponse = await this.parseFinalAnswerAndHydrate(subagentResponse, cwd, options.workingDirectory, token); diff --git a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts index 93727557d3e0c..7ff37c5f668f9 100644 --- a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts @@ -5,13 +5,14 @@ import type * as vscode from 'vscode'; import { expect, suite, test } from 'vitest'; +import { ChatFetchResponseType } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey } from '../../../../platform/configuration/common/configurationService'; import { URI } from '../../../../util/vs/base/common/uri'; import { toolCategories, ToolCategory, ToolName } from '../../common/toolNames'; import { ToolRegistry } from '../../common/toolsRegistry'; // Ensure side-effect registration -import '../searchSubagentTool'; +import { CONTEXT_OVERFLOW_FALLBACK, mapLoopResponseToText } from '../searchSubagentTool'; /** * Returns an invokeFunction stub that dequeues outcomes in call order. @@ -273,3 +274,65 @@ suite('SearchSubagentTool', () => { }); }); }); + +suite('mapLoopResponseToText', () => { + function makeRound(response: string) { + return { id: 'r', response, toolInputRetry: 0, toolCalls: [] }; + } + + test('success returns the last tool-call round response', () => { + const text = mapLoopResponseToText({ + response: { type: ChatFetchResponseType.Success }, + toolCallRounds: [makeRound('first'), makeRound('last')], + round: makeRound('final-round'), + } as any); + expect(text).toBe('last'); + }); + + test('success falls back to round.response when toolCallRounds is empty', () => { + const text = mapLoopResponseToText({ + response: { type: ChatFetchResponseType.Success }, + toolCallRounds: [], + round: makeRound('final-round'), + } as any); + expect(text).toBe('final-round'); + }); + + test('success returns empty string when no responses are available', () => { + const text = mapLoopResponseToText({ + response: { type: ChatFetchResponseType.Success }, + toolCallRounds: [], + round: makeRound(''), + } as any); + expect(text).toBe(''); + }); + + test('context-overflow BadRequest is converted to the benign final_answer fallback', () => { + const overflowReasons = [ + 'context_length_exceeded', + 'Request too large for model', + 'prompt is too long for this model', + 'maximum context length is 200000 tokens', + ]; + + for (const reason of overflowReasons) { + const text = mapLoopResponseToText({ + response: { type: ChatFetchResponseType.BadRequest, reason }, + toolCallRounds: [], + round: makeRound('ignored'), + } as any); + expect(text, `reason "${reason}" should map to fallback`).toBe(CONTEXT_OVERFLOW_FALLBACK); + } + }); + + test('non-overflow failures surface the response type and reason to the main agent', () => { + const text = mapLoopResponseToText({ + response: { type: ChatFetchResponseType.Failed, reason: 'network down' }, + toolCallRounds: [], + round: makeRound(''), + } as any); + expect(text).toContain(ChatFetchResponseType.Failed); + expect(text).toContain('network down'); + expect(text).not.toBe(CONTEXT_OVERFLOW_FALLBACK); + }); +}); diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts index c72843e7e1e6e..2405e9c4fa241 100644 --- a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -497,6 +497,8 @@ describe('SessionStoreSqlTool', () => { '### Cost Tips', 'usage_input_tokens', 'usage_output_tokens', 'usage_model', 'agent_name', + `'VS Code Chat'`, + `'GitHub Copilot Chat'`, 'assistant.usage', 'local SQLite', 'chat.sessionSync.enabled', diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 9927b03f85123..21f16d4c6a510 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -454,11 +454,9 @@ export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint n === 'gpt-5-5' || n.startsWith('claude-sonnet-4-5') || n.startsWith('claude-sonnet-4-6') || - n.startsWith('claude-opus-4-5') || - n.startsWith('claude-opus-4-6') || - n.startsWith('claude-opus-4-7'); + (!n.startsWith('claude-opus-4-1') && n.startsWith('claude-opus-4-')); }; - return matches(id) || matches(family) || isHiddenModelM(id); + return matches(id) || matches(family) || isHiddenModelM(family); } /** diff --git a/extensions/copilot/src/platform/parser/node/parserImpl.ts b/extensions/copilot/src/platform/parser/node/parserImpl.ts index 64230a59a2305..d6a04eb7d5b97 100644 --- a/extensions/copilot/src/platform/parser/node/parserImpl.ts +++ b/extensions/copilot/src/platform/parser/node/parserImpl.ts @@ -18,7 +18,7 @@ import Parser = require('web-tree-sitter'); export { _getDocumentableNodeIfOnIdentifier, _getNodeToDocument, NodeToDocumentContext } from './docGenParsing'; export { _dispose } from './parserWithCaching'; export { _getNodeMatchingSelection } from './selectionParsing'; -export { _findLastTest, _getTestableNode, _getTestableNodes } from './testGenParsing'; +export { _findLastTest } from './testGenParsing'; function queryCoarseScopes(language: WASMLanguage, root: Parser.SyntaxNode): Parser.QueryMatch[] { const queries = coarseScopesQuery[language]; diff --git a/extensions/copilot/src/platform/parser/node/parserService.ts b/extensions/copilot/src/platform/parser/node/parserService.ts index 22ff28e839251..4c5d1851dab49 100644 --- a/extensions/copilot/src/platform/parser/node/parserService.ts +++ b/extensions/copilot/src/platform/parser/node/parserService.ts @@ -10,7 +10,6 @@ import { TextDocumentSnapshot } from '../../editing/common/textDocumentSnapshot' import { BlockNameDetail, DetailBlock, QueryMatchTree } from './chunkGroupTypes'; import { OverlayNode, TreeSitterExpressionInfo, TreeSitterOffsetRange, TreeSitterPointRange } from './nodes'; import type * as parser from './parserImpl'; -import { TestableNode } from './testGenParsing'; import { WASMLanguage } from './treeSitterLanguages'; export const IParserService = createServiceIdentifier('IParserService'); @@ -60,11 +59,6 @@ export interface TreeSitterAST { * @param range The range to document. */ getDocumentableNodeIfOnIdentifier(range: TreeSitterOffsetRange): Promise<{ identifier: string; nodeRange?: TreeSitterOffsetRange } | undefined>; - /** - * @param range The range to test. - */ - getTestableNode(range: TreeSitterOffsetRange): Promise; - getTestableNodes(): Promise; /** * Starting from the smallest AST node that wraps `selection` and climbs up the AST until it sees a "documentable" node. * See {@link isDocumentableNode} for definition of a "documentable" node. diff --git a/extensions/copilot/src/platform/parser/node/parserServiceImpl.ts b/extensions/copilot/src/platform/parser/node/parserServiceImpl.ts index b4cc637ab9d9d..9ef6b70be426b 100644 --- a/extensions/copilot/src/platform/parser/node/parserServiceImpl.ts +++ b/extensions/copilot/src/platform/parser/node/parserServiceImpl.ts @@ -52,8 +52,6 @@ export class ParserServiceImpl implements IParserService { getTypeReferences: (selection: TreeSitterOffsetRange) => parserProxy._getTypeReferences(wasmLanguage, source, selection), getSymbols: (selection: TreeSitterOffsetRange) => parserProxy._getSymbols(wasmLanguage, source, selection), getDocumentableNodeIfOnIdentifier: (range: TreeSitterOffsetRange) => parserProxy._getDocumentableNodeIfOnIdentifier(wasmLanguage, source, range), - getTestableNode: (range: TreeSitterOffsetRange) => parserProxy._getTestableNode(wasmLanguage, source, range), - getTestableNodes: () => parserProxy._getTestableNodes(wasmLanguage, source), getNodeToExplain: (range: TreeSitterOffsetRange) => parserProxy._getNodeToExplain(wasmLanguage, source, range), getNodeToDocument: (range: TreeSitterOffsetRange) => parserProxy._getNodeToDocument(wasmLanguage, source, range), getFineScopes: (selection: TreeSitterOffsetRange) => parserProxy._getFineScopes(wasmLanguage, source, selection), diff --git a/extensions/copilot/src/platform/parser/node/testGenParsing.ts b/extensions/copilot/src/platform/parser/node/testGenParsing.ts index de985162636b9..1f17e9bc35c12 100644 --- a/extensions/copilot/src/platform/parser/node/testGenParsing.ts +++ b/extensions/copilot/src/platform/parser/node/testGenParsing.ts @@ -3,157 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { QueryCapture } from 'web-tree-sitter'; -import { uniqueFilter } from '../../../util/vs/base/common/arrays'; -import { assertType } from '../../../util/vs/base/common/types'; -import { Node, TreeSitterOffsetRange } from './nodes'; +import { TreeSitterOffsetRange } from './nodes'; import { _parse } from './parserWithCaching'; import { runQueries } from './querying'; import { WASMLanguage } from './treeSitterLanguages'; -import { testableNodeQueries, testInSuiteQueries } from './treeSitterQueries'; - -export type TestableNode = { - identifier: { - name: string; - range: TreeSitterOffsetRange; - }; - node: Node; -}; - - -export async function _getTestableNode( - language: WASMLanguage, - source: string, - range: TreeSitterOffsetRange -): Promise { - const treeRef = await _parse(language, source); - - try { - const queryCaptures = runQueries( - testableNodeQueries[language], - treeRef.tree.rootNode - ).flatMap(({ captures }) => captures); // @ulugbekna: keep in mind: there's duplication of captures - - const symbolKindToIdents = new Map(); - - for (const capture of queryCaptures) { - const [symbolKind, name] = capture.name.split('.'); - if (name !== 'identifier') { - continue; - } - - const idents = symbolKindToIdents.get(symbolKind) || []; - idents.push(capture); - symbolKindToIdents.set(symbolKind, idents); - } - - let minimalTestableNode: TestableNode | null = null; - - for (const capture of queryCaptures) { - const [symbolKind, name] = capture.name.split('.'); - - if (name !== undefined || // ensure we traverse only declarations (and child nodes such as `method.identifier` or `method.accessibility_modifier`) - !TreeSitterOffsetRange.doesContain(capture.node, range) // ensure this declaration contains our range of interest - ) { - continue; - } - - // ensure we pick range-wise minimal testable node - if (minimalTestableNode !== null && - TreeSitterOffsetRange.len(minimalTestableNode.node) < TreeSitterOffsetRange.len(capture.node) - ) { - continue; - } - - const idents = symbolKindToIdents.get(symbolKind); - - assertType(idents !== undefined, `must have seen identifier for symbol kind '${symbolKind}' (lang: ${language})`); - - const nodeIdent = idents.find(ident => TreeSitterOffsetRange.doesContain(capture.node, ident.node)); - - assertType(nodeIdent !== undefined, `must have seen identifier for symbol '${symbolKind}' (lang: ${language})`); - - minimalTestableNode = { - identifier: { - name: nodeIdent.node.text, - range: TreeSitterOffsetRange.ofSyntaxNode(nodeIdent.node), - }, - node: Node.ofSyntaxNode(capture.node), - }; - } - - return minimalTestableNode; - - } catch (e) { - console.error('getTestableNode: Unexpected error', e); - return null; - } finally { - treeRef.dispose(); - } -} - -export async function _getTestableNodes( - language: WASMLanguage, - source: string, -): Promise { - const treeRef = await _parse(language, source); - - try { - const queryCaptures = runQueries( - testableNodeQueries[language], - treeRef.tree.rootNode - ) - .flatMap(({ captures }) => captures) - .filter(uniqueFilter((c: QueryCapture) => [c.node.startIndex, c.node.endIndex].toString())); - - const symbolKindToIdents = new Map(); - - for (const capture of queryCaptures) { - const [symbolKind, name] = capture.name.split('.'); - if (name !== 'identifier') { - continue; - } - - const idents = symbolKindToIdents.get(symbolKind) || []; - idents.push(capture); - symbolKindToIdents.set(symbolKind, idents); - } - - const testableNodes: TestableNode[] = []; - - for (const capture of queryCaptures) { - if (capture.name.includes('.')) { - continue; - } - - const symbolKind = capture.name; - - const idents = symbolKindToIdents.get(symbolKind); - - assertType(idents !== undefined, `must have seen identifier for symbol kind '${symbolKind}' (lang: ${language})`); - - const nodeIdent = idents.find(ident => TreeSitterOffsetRange.doesContain(capture.node, ident.node)); - - assertType(nodeIdent !== undefined, `must have seen identifier for symbol '${symbolKind}' (lang: ${language})`); - - testableNodes.push({ - identifier: { - name: nodeIdent.node.text, - range: TreeSitterOffsetRange.ofSyntaxNode(nodeIdent.node), - }, - node: Node.ofSyntaxNode(capture.node), - }); - } - - return testableNodes; - - } catch (e) { - console.error('getTestableNodes: Unexpected error', e); - return null; - } finally { - treeRef.dispose(); - } -} +import { testInSuiteQueries } from './treeSitterQueries'; export async function _findLastTest(lang: WASMLanguage, src: string): Promise { diff --git a/extensions/copilot/src/platform/parser/node/treeSitterQueries.ts b/extensions/copilot/src/platform/parser/node/treeSitterQueries.ts index c1c73fd6d25fa..fad2a68bb4ad7 100644 --- a/extensions/copilot/src/platform/parser/node/treeSitterQueries.ts +++ b/extensions/copilot/src/platform/parser/node/treeSitterQueries.ts @@ -480,134 +480,6 @@ export const docCommentQueries: LanguageQueryMap = q({ ], }); -export const testableNodeQueries: LanguageQueryMap = q({ - [WASMLanguage.JavaScript]: [ - treeSitterQuery.javascript`[ - (function_declaration - (identifier) @function.identifier - ) @function - - (generator_function_declaration - name: (identifier) @generator_function.identifier - ) @generator_function - - (class_declaration - name: (identifier) @class.identifier ;; note: (type_identifier) in typescript - body: (class_body - (method_definition - name: (property_identifier) @method.identifier - ) @method - ) - ) @class - ]` - ], - ...forLanguages([WASMLanguage.TypeScript, WASMLanguage.TypeScriptTsx], - [treeSitterQuery.typescript`[ - (function_declaration - (identifier) @function.identifier - ) @function - - (generator_function_declaration - name: (identifier) @generator_function.identifier - ) @generator_function - - (class_declaration - name: (type_identifier) @class.identifier - body: (class_body - (method_definition - (accessibility_modifier)? @method.accessibility_modifier - name: (property_identifier) @method.identifier - (#not-eq? @method.accessibility_modifier "private") - ) @method - ) - ) @class - ]`] - ), - [WASMLanguage.Python]: [ - treeSitterQuery.python`[ - (function_definition - name: (identifier) @function.identifier - ) @function - ]` - ], - [WASMLanguage.Go]: [ - treeSitterQuery.go`[ - (function_declaration - name: (identifier) @function.identifier - ) @function - - (method_declaration - name: (field_identifier) @method.identifier - ) @method - ]` - ], - [WASMLanguage.Ruby]: [ - treeSitterQuery.ruby`[ - (method - name: (identifier) @method.identifier - ) @method - - (singleton_method - name: (_) @singleton_method.identifier - ) @singleton_method - ]` - ], - [WASMLanguage.Csharp]: [ - treeSitterQuery.csharp`[ - (constructor_declaration - (identifier) @constructor.identifier - ) @constructor - - (destructor_declaration - (identifier) @destructor.identifier - ) @destructor - - (method_declaration - (identifier) @method.identifier - ) @method - - (local_function_statement - (identifier) @local_function.identifier - ) @local_function - ]` - ], - [WASMLanguage.Cpp]: [ // FIXME@ulugbekna: #7769 enrich with class/methods - treeSitterQuery.cpp`[ - (function_definition - (_ - (identifier) @identifier) - ) @function - ]` - ], - [WASMLanguage.Java]: [ - treeSitterQuery.java`(class_declaration - name: (_) @class.identifier - body: (_ - [ - (constructor_declaration - (modifiers)? @constructor.modifiers - (#not-eq? @constructor.modifiers "private") - name: (identifier) @constructor.identifier - ) @constructor - - (method_declaration - (modifiers)? @method.modifiers - (#not-eq? @method.modifiers "private") - name: (identifier) @method.identifier - ) @method - ] - ) - ) @class` - ], - [WASMLanguage.Rust]: [ - treeSitterQuery.rust`[ - (function_item - (identifier) @function.identifier - ) @function - ]` - ] -}); - export const symbolQueries: LanguageQueryMap = q({ [WASMLanguage.JavaScript]: [ treeSitterQuery.javascript`[ diff --git a/extensions/copilot/src/platform/parser/test/node/getTestableNode.js.spec.ts b/extensions/copilot/src/platform/parser/test/node/getTestableNode.js.spec.ts deleted file mode 100644 index 651ffaabb0b3c..0000000000000 --- a/extensions/copilot/src/platform/parser/test/node/getTestableNode.js.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { outdent } from 'outdent'; -import { afterAll, expect, suite, test } from 'vitest'; -import { _dispose } from '../../node/parserWithCaching'; -import { WASMLanguage } from '../../node/treeSitterLanguages'; -import { srcWithAnnotatedTestableNode } from './getTestableNode.util'; - -suite('getTestableNode - js', () => { - afterAll(() => _dispose()); - - function run(annotatedSrc: string) { - return srcWithAnnotatedTestableNode( - WASMLanguage.JavaScript, - annotatedSrc, - ); - } - - test('function declaration', async () => { - const result = await run( - outdent` - function <>(a: number, b: number): number { - return a + b; - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "function add(a: number, b: number): number { - return a + b; - }" - `); - }); - - test('method', async () => { - const result = await run( - outdent` - class Foo { - <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('public method', async () => { - const result = await run( - outdent` - class Foo { - <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('does not capture private method', async () => { - const result = await run( - outdent` - class Foo { - private <<#method>>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(`"testable node NOT found"`); - }); - - test('static method', async () => { - const result = await run( - outdent` - class Foo { - static <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('private static method', async () => { - const result = await run( - outdent` - class Foo { - static <<#method>>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(`"testable node NOT found"`); - }); - - test('public static method', async () => { - const result = await run( - outdent` - class Foo { - public static <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - public static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); -}); diff --git a/extensions/copilot/src/platform/parser/test/node/getTestableNode.ts.spec.ts b/extensions/copilot/src/platform/parser/test/node/getTestableNode.ts.spec.ts deleted file mode 100644 index 8f60da9b0bc24..0000000000000 --- a/extensions/copilot/src/platform/parser/test/node/getTestableNode.ts.spec.ts +++ /dev/null @@ -1,247 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { outdent } from 'outdent'; -import { afterAll, expect, suite, test } from 'vitest'; -import { _dispose } from '../../node/parserWithCaching'; -import { WASMLanguage } from '../../node/treeSitterLanguages'; -import { srcWithAnnotatedTestableNode } from './getTestableNode.util'; - -suite('getTestableNode - ts', () => { - afterAll(() => _dispose()); - - function run(annotatedSrc: string) { - return srcWithAnnotatedTestableNode( - WASMLanguage.TypeScript, - annotatedSrc, - ); - } - - test('function declaration', async () => { - const result = await run( - outdent` - function <>(a: number, b: number): number { - return a + b; - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "function add(a: number, b: number): number { - return a + b; - }" - `); - }); - - test('method', async () => { - const result = await run( - outdent` - class Foo { - <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('public method', async () => { - const result = await run( - outdent` - class Foo { - <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('several public methods', async () => { - const result = await run( - outdent` - class Foo { - method(a: number, b: number): number { - return a + b; - } - - method2(a: number, b: number): number { - return a + b; - } - - method3(a: number, b: number): number { - return a + b; - } - - <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - - method2(a: number, b: number): number { - return a + b; - } - - method3(a: number, b: number): number { - return a + b; - } - - method4(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('does not capture private method', async () => { - const result = await run( - outdent` - class Foo { - private <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(`"testable node NOT found"`); - }); - - test('static method', async () => { - const result = await run( - outdent` - class Foo { - static <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('private static method', async () => { - const result = await run( - outdent` - class Foo { - private static <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(`"testable node NOT found"`); - }); - - - test('public static method', async () => { - const result = await run( - outdent` - class Foo { - public static <>(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - public static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration', async () => { - const result = await run( - outdent` - export class <<>>Foo { - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration with prop and method', async () => { - const result = await run( - outdent` - export class <<>>Foo { - bar = 1; - - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - bar = 1; - - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration with prop and static method', async () => { - const result = await run( - outdent` - export class Foo { - bar = 1; - - static <<>>method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - bar = 1; - - static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); -}); diff --git a/extensions/copilot/src/platform/parser/test/node/getTestableNode.util.ts b/extensions/copilot/src/platform/parser/test/node/getTestableNode.util.ts deleted file mode 100644 index b6d2f3366d039..0000000000000 --- a/extensions/copilot/src/platform/parser/test/node/getTestableNode.util.ts +++ /dev/null @@ -1,52 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { deannotateSrc } from '../../../../util/common/test/annotatedSrc'; -import { _getTestableNode } from '../../node/testGenParsing'; -import { WASMLanguage } from '../../node/treeSitterLanguages'; -import { insertRangeMarkers, MarkerRange } from './markers'; - -export async function srcWithAnnotatedTestableNode(language: WASMLanguage, source: string, includeSelection = false) { - const { deannotatedSrc, annotatedRange: selection } = deannotateSrc(source); - - const result = await _getTestableNode( - language, - deannotatedSrc, - selection - ); - - if (result === null) { - return 'testable node NOT found'; - } - - const markers: MarkerRange[] = []; - - const ident = result.identifier; - markers.push({ - startIndex: ident.range.startIndex, - endIndex: ident.range.endIndex, - kind: 'IDENT', - }); - - if (includeSelection) { - markers.push( - { - startIndex: selection.startIndex, - endIndex: selection.endIndex, - kind: 'SELECTION' - } - ); - } - - markers.push( - { - startIndex: result.node.startIndex, - endIndex: result.node.endIndex, - kind: `NODE(${result.node.type})`, - } - ); - - return insertRangeMarkers(deannotatedSrc, markers); -} diff --git a/extensions/copilot/src/platform/parser/test/node/getTestableNodes.ts.spec.ts b/extensions/copilot/src/platform/parser/test/node/getTestableNodes.ts.spec.ts deleted file mode 100644 index 6eb9e2d7a2bc3..0000000000000 --- a/extensions/copilot/src/platform/parser/test/node/getTestableNodes.ts.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { outdent } from 'outdent'; -import { afterAll, expect, suite, test } from 'vitest'; -import { _dispose } from '../../node/parserWithCaching'; -import { WASMLanguage } from '../../node/treeSitterLanguages'; -import { annotTestableNodes } from './getTestableNodes.util'; - -suite('getTestableNodes - ts', () => { - afterAll(() => _dispose()); - - function run(annotatedSrc: string) { - return annotTestableNodes( - WASMLanguage.TypeScript, - annotatedSrc, - ); - } - - test('function declaration', async () => { - const result = await run( - outdent` - function add(a: number, b: number): number { - return a + b; - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "function add(a: number, b: number): number { - return a + b; - }" - `); - }); - - test('method', async () => { - const result = await run( - outdent` - class Foo { - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('public method', async () => { - const result = await run( - outdent` - class Foo { - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('several public methods', async () => { - const result = await run( - outdent` - class Foo { - method(a: number, b: number): number { - return a + b; - } - - method2(a: number, b: number): number { - return a + b; - } - - method3(a: number, b: number): number { - return a + b; - } - - method4(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - method(a: number, b: number): number { - return a + b; - } - - method2(a: number, b: number): number { - return a + b; - } - - method3(a: number, b: number): number { - return a + b; - } - - method4(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('captures mix', async () => { - const result = await run( - outdent` - class Foo { - methodPub() { - } - - private method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - methodPub() { - } - - private method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('does NOT capture private method', async () => { - const result = await run( - outdent` - class Foo { - private method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - private method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('static method', async () => { - const result = await run( - outdent` - class Foo { - static method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('private static method', async () => { - const result = await run( - outdent` - class Foo { - private static method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - private static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - - test('public static method', async () => { - const result = await run( - outdent` - class Foo { - public static method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "class Foo { - public static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration', async () => { - const result = await run( - outdent` - export class Foo { - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration with prop and method', async () => { - const result = await run( - outdent` - export class Foo { - bar = 1; - - method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - bar = 1; - - method(a: number, b: number): number { - return a + b; - } - }" - `); - }); - - test('class declaration with prop and static method', async () => { - const result = await run( - outdent` - export class Foo { - bar = 1; - - static method(a: number, b: number): number { - return a + b; - } - } - `, - ); - expect(result).toMatchInlineSnapshot(` - "export class Foo { - bar = 1; - - static method(a: number, b: number): number { - return a + b; - } - }" - `); - }); -}); diff --git a/extensions/copilot/src/platform/parser/test/node/getTestableNodes.util.ts b/extensions/copilot/src/platform/parser/test/node/getTestableNodes.util.ts deleted file mode 100644 index 1edc04a5b2c10..0000000000000 --- a/extensions/copilot/src/platform/parser/test/node/getTestableNodes.util.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { _getTestableNodes } from '../../node/testGenParsing'; -import { WASMLanguage } from '../../node/treeSitterLanguages'; -import { insertRangeMarkers } from './markers'; - -export async function annotTestableNodes(language: WASMLanguage, source: string, includeSelection = false) { - - const result = await _getTestableNodes( - language, - source, - ); - - if (result === null) { - return 'testable node NOT found'; - } - - const markers = result.flatMap(node => { - return [ - { - startIndex: node.node.startIndex, - endIndex: node.node.endIndex, - kind: 'NODE', - }, - { - startIndex: node.identifier.range.startIndex, - endIndex: node.identifier.range.endIndex, - kind: 'IDENT', - } - ]; - }); - - return insertRangeMarkers(source, markers); -} diff --git a/extensions/copilot/test/base/simulationContext.ts b/extensions/copilot/test/base/simulationContext.ts index f11a38764fedd..7d74cb7a46cf2 100644 --- a/extensions/copilot/test/base/simulationContext.ts +++ b/extensions/copilot/test/base/simulationContext.ts @@ -8,7 +8,6 @@ import path from 'path'; import { ApiEmbeddingsIndex, IApiEmbeddingsIndex } from '../../src/extension/context/node/resolvers/extensionApi'; import { ConversationStore, IConversationStore } from '../../src/extension/conversationStore/node/conversationStore'; import { IIntentService, IntentService } from '../../src/extension/intents/node/intentService'; -import { ITestGenInfoStorage, TestGenInfoStorage } from '../../src/extension/intents/node/testIntent/testInfoStorage'; import { ILinkifyService, LinkifyService } from '../../src/extension/linkify/common/linkifyService'; import { ChatMLFetcherImpl } from '../../src/extension/prompt/node/chatMLFetcher'; import { createExtensionUnitTestingServices, ISimulationModelConfig } from '../../src/extension/test/node/services'; @@ -288,7 +287,6 @@ export async function createSimulationAccessor( testingServiceCollection.define(INaiveChunkingService, new SyncDescriptor(NaiveChunkingService)); testingServiceCollection.define(ILinkifyService, new SyncDescriptor(LinkifyService)); testingServiceCollection.define(ITestProvider, new SyncDescriptor(NullTestProvider)); - testingServiceCollection.define(ITestGenInfoStorage, new SyncDescriptor(TestGenInfoStorage)); testingServiceCollection.define(IConversationStore, new SyncDescriptor(ConversationStore)); testingServiceCollection.define(IReviewService, new SyncDescriptor(SimulationReviewService)); testingServiceCollection.define(IGitExtensionService, new SyncDescriptor(NullGitExtensionService)); diff --git a/package-lock.json b/package-lock.json index 648cee3951032..96d43ce51a502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.2.0", + "@microsoft/mxc-sdk": "0.2.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-13", @@ -1935,9 +1935,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.0.tgz", - "integrity": "sha512-xgWTV0nvIzl+IjlIhLGw++/A1eeZYORDoMLGLlDpSE8tMPWLbQIF627Xsb0pkb04MB9vtZl9P+RRNB7fwS3PXA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.1.tgz", + "integrity": "sha512-1dL42Abc1ocapZR01aPeSEcvuzWuvOslmWNZvdYs6+yTVqAnpWrMk+aFf0Odry9SqJbcW9FABYzPlFtJW6clAQ==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/package.json b/package.json index b243a5226e8e9..7815d6980dd6f 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.2.0", + "@microsoft/mxc-sdk": "0.2.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-13", diff --git a/remote/package-lock.json b/remote/package-lock.json index 3e264c2f26467..9f4b5e57d9857 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -12,7 +12,7 @@ "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.2.0", + "@microsoft/mxc-sdk": "0.2.1", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.1", "@vscode/deviceid": "^0.1.1", @@ -281,9 +281,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.0.tgz", - "integrity": "sha512-xgWTV0nvIzl+IjlIhLGw++/A1eeZYORDoMLGLlDpSE8tMPWLbQIF627Xsb0pkb04MB9vtZl9P+RRNB7fwS3PXA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.1.tgz", + "integrity": "sha512-1dL42Abc1ocapZR01aPeSEcvuzWuvOslmWNZvdYs6+yTVqAnpWrMk+aFf0Odry9SqJbcW9FABYzPlFtJW6clAQ==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/remote/package.json b/remote/package.json index 75b0e695f5553..246fdf5b03982 100644 --- a/remote/package.json +++ b/remote/package.json @@ -7,7 +7,7 @@ "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.2.0", + "@microsoft/mxc-sdk": "0.2.1", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.1", "@vscode/deviceid": "^0.1.1", diff --git a/src/vs/base/common/oauth.ts b/src/vs/base/common/oauth.ts index ea5a3cbbe35d3..3e4d6133360a9 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -11,6 +11,93 @@ export const AUTH_SERVER_METADATA_DISCOVERY_PATH = `${WELL_KNOWN_ROUTE}/oauth-au export const OPENID_CONNECT_DISCOVERY_PATH = `${WELL_KNOWN_ROUTE}/openid-configuration`; export const AUTH_SCOPE_SEPARATOR = ' '; +/** + * RFC 8693 grant type for OAuth token exchange. + */ +export const GRANT_TYPE_TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'; + +/** + * RFC 8693 token type for an OAuth 2.0 access token used as the `subject_token` + * during a token exchange. + */ +export const TOKEN_TYPE_ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token'; + +/** + * Token type for an OpenID Connect ID Token. Used as the `subject_token_type` in + * the IdP-side token exchange that mints an ID-JAG. + */ +export const TOKEN_TYPE_ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token'; + +/** + * Token type for an Identity Assertion Authorization Grant (ID-JAG) used in + * Cross App Access (XAA) flows. + */ +export const TOKEN_TYPE_ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag'; + +/** + * RFC 7523 grant type used to exchange a JWT assertion (e.g. an ID-JAG) for an + * access token at the resource's authorization server. + */ +export const GRANT_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; + +/** + * Build the request body for the IdP-side token exchange that mints an ID-JAG + * for the requested audience. See draft-ietf-oauth-identity-assertion-authz-grant. + * + * @param clientId the requesting app's client_id at the IdP. + * @param clientSecret the requesting app's client_secret at the IdP, if applicable. + * Omit (or pass `undefined`) for public clients (`token_endpoint_auth_method=none`). + * @param idToken the OpenID Connect `id_token` previously issued by the IdP to + * the requesting app. Per the spec the subject token MUST be an ID Token + * (not an access token). + * @param audience the *authorization server* URL of the resource (the issuer + * that will redeem the ID-JAG). Required. + * @param resource the resource indicator (RFC 8707) — the URL of the actual + * protected resource (e.g. the MCP server URL). Optional but typically required + * in practice. + * @param scopes scopes the requesting app wants granted at the resource. + */ +export function buildIdJagExchangeBody(clientId: string, clientSecret: string | undefined, idToken: string, audience: string, resource: string | undefined, scopes: readonly string[]): URLSearchParams { + const body = new URLSearchParams(); + body.append('client_id', clientId); + if (clientSecret) { + body.append('client_secret', clientSecret); + } + body.append('grant_type', GRANT_TYPE_TOKEN_EXCHANGE); + body.append('subject_token', idToken); + body.append('subject_token_type', TOKEN_TYPE_ID_TOKEN); + body.append('requested_token_type', TOKEN_TYPE_ID_JAG); + body.append('audience', audience); + if (resource) { + body.append('resource', resource); + } + if (scopes.length) { + body.append('scope', scopes.join(AUTH_SCOPE_SEPARATOR)); + } + return body; +} + +/** + * Build the request body sent to a resource server's authorization server to + * redeem an ID-JAG for a resource-scoped access token (RFC 7523 JWT-bearer grant). + */ +export function buildResourceRedemptionBody(clientId: string, clientSecret: string | undefined, idJag: string, resource: string | undefined, scopes: readonly string[]): URLSearchParams { + const body = new URLSearchParams(); + body.append('client_id', clientId); + if (clientSecret) { + body.append('client_secret', clientSecret); + } + body.append('grant_type', GRANT_TYPE_JWT_BEARER); + body.append('assertion', idJag); + if (resource) { + body.append('resource', resource); + } + if (scopes.length) { + body.append('scope', scopes.join(AUTH_SCOPE_SEPARATOR)); + } + return body; +} + //#region types /** diff --git a/src/vs/base/test/common/oauth.test.ts b/src/vs/base/test/common/oauth.test.ts index c554a8b7bddb3..fdfaf682a96ef 100644 --- a/src/vs/base/test/common/oauth.test.ts +++ b/src/vs/base/test/common/oauth.test.ts @@ -6,6 +6,8 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { + buildIdJagExchangeBody, + buildResourceRedemptionBody, getClaimsFromJWT, getDefaultMetadataForUrl, isAuthorizationAuthorizeResponse, @@ -2254,4 +2256,60 @@ suite('OAuth', () => { assert.strictEqual(headers['Accept'], 'application/json'); }); }); + + suite('Cross App Access (ID-JAG) wire format', () => { + // Spec: draft-ietf-oauth-identity-assertion-authz-grant-03 + test('buildIdJagExchangeBody emits the exact spec parameters', () => { + const body = buildIdJagExchangeBody( + 'my_idp_client_id', + 'secret_xyz', + '', + 'https://auth.resource.example.com', + 'https://api.resource.example.com', + ['todos.read', 'mcp.access'], + ); + + assert.strictEqual(body.get('client_id'), 'my_idp_client_id'); + assert.strictEqual(body.get('client_secret'), 'secret_xyz'); + assert.strictEqual(body.get('grant_type'), 'urn:ietf:params:oauth:grant-type:token-exchange'); + assert.strictEqual(body.get('subject_token'), ''); + assert.strictEqual(body.get('subject_token_type'), 'urn:ietf:params:oauth:token-type:id_token'); + assert.strictEqual(body.get('requested_token_type'), 'urn:ietf:params:oauth:token-type:id-jag'); + assert.strictEqual(body.get('audience'), 'https://auth.resource.example.com'); + assert.strictEqual(body.get('resource'), 'https://api.resource.example.com'); + assert.strictEqual(body.get('scope'), 'todos.read mcp.access'); + }); + + test('buildIdJagExchangeBody omits client_secret when not provided', () => { + const body = buildIdJagExchangeBody( + 'public_client_id', + undefined, + '', + 'https://auth.resource.example.com', + undefined, + [], + ); + + assert.strictEqual(body.has('client_secret'), false); + assert.strictEqual(body.has('resource'), false); + assert.strictEqual(body.has('scope'), false); + }); + + test('buildResourceRedemptionBody emits an RFC 7523 JWT-bearer grant', () => { + const body = buildResourceRedemptionBody( + 'my_idp_client_id-at-todo0', + 'secret_xyz', + '', + 'https://api.resource.example.com', + ['todos.read', 'mcp.access'], + ); + + assert.strictEqual(body.get('client_id'), 'my_idp_client_id-at-todo0'); + assert.strictEqual(body.get('client_secret'), 'secret_xyz'); + assert.strictEqual(body.get('grant_type'), 'urn:ietf:params:oauth:grant-type:jwt-bearer'); + assert.strictEqual(body.get('assertion'), ''); + assert.strictEqual(body.get('resource'), 'https://api.resource.example.com'); + assert.strictEqual(body.get('scope'), 'todos.read mcp.access'); + }); + }); }); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 489df63e26c30..0d55aec6b497b 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -24,7 +24,7 @@ import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/ import { AgentHostPermissionMode, IAgentHostPermissionService } from '../common/agentHostPermissionService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type CustomizationRef, type RootState } from '../common/state/sessionState.js'; +import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; @@ -873,7 +873,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * the customization, but should not need to write them. Grants are * deduped per connection and revoked when the connection closes. */ - private _grantImplicitReadsForCustomizations(refs: readonly CustomizationRef[]): void { + private _grantImplicitReadsForCustomizations(refs: readonly ClientPluginCustomization[]): void { for (const ref of refs) { let uri: URI; try { diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 421d067001a08..c185d3c41be55 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -5,7 +5,8 @@ import { localize } from '../../../nls.js'; import { createSchema, schemaProperty } from './agentHostSchema.js'; -import { type CustomizationRef } from './state/protocol/state.js'; +import { CustomizationType, type Customization } from './state/protocol/state.js'; +import { customizationId } from './state/sessionState.js'; /** * Well-known root-config keys used by the platform to configure agent-host @@ -23,8 +24,21 @@ export const enum AgentHostConfigKey { DisableCustomTerminalTool = 'disableCustomTerminalTool', } +/** + * Persisted on-disk shape for a host-configured plugin. Kept stable across + * the customization protocol refactor so existing `agent-host-config.json` + * files keep working; entries are mapped to the new + * {@link Customization} shape at read time by + * {@link getAgentHostConfiguredCustomizations}. + */ +interface IPersistedCustomizationConfigEntry { + uri: string; + displayName: string; + description?: string; +} + export const agentHostCustomizationConfigSchema = createSchema({ - [AgentHostConfigKey.Customizations]: schemaProperty({ + [AgentHostConfigKey.Customizations]: schemaProperty({ type: 'array', title: localize('agentHost.config.customizations.title', "Plugins"), description: localize('agentHost.config.customizations.description', "Plugins configured on this agent host and available to remote sessions."), @@ -63,12 +77,33 @@ export const agentHostCustomizationConfigSchema = createSchema({ }); export const defaultAgentHostCustomizationConfigValues = { - [AgentHostConfigKey.Customizations]: [] as CustomizationRef[], + [AgentHostConfigKey.Customizations]: [] as IPersistedCustomizationConfigEntry[], }; -export function getAgentHostConfiguredCustomizations(values: Record | undefined): readonly CustomizationRef[] { +/** + * Reads the persisted (legacy-shaped) plugin entries from the agent-host + * root config and lifts them into the new {@link Customization} container + * shape used by the rest of the platform. + */ +export function getAgentHostConfiguredCustomizations(values: Record | undefined): readonly Customization[] { const raw = values?.[AgentHostConfigKey.Customizations]; - return agentHostCustomizationConfigSchema.validate(AgentHostConfigKey.Customizations, raw) + const entries = agentHostCustomizationConfigSchema.validate(AgentHostConfigKey.Customizations, raw) ? raw : defaultAgentHostCustomizationConfigValues[AgentHostConfigKey.Customizations]; + return entries.map(toContainerCustomization); } + +/** + * Lifts a persisted plugin config entry into the new + * {@link Customization} container shape. + */ +export function toContainerCustomization(entry: IPersistedCustomizationConfigEntry): Customization { + return { + type: CustomizationType.Plugin, + id: customizationId(entry.uri), + uri: entry.uri, + name: entry.displayName, + enabled: true, + }; +} + diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index 5f0a94c815bd5..2c2517f3c9fb4 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -5,7 +5,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { CustomizationRef, SessionCustomization } from './state/sessionState.js'; +import type { ClientPluginCustomization, Customization } from './state/sessionState.js'; export const IAgentPluginManager = createDecorator('agentPluginManager'); @@ -14,7 +14,7 @@ export const IAgentPluginManager = createDecorator('agentPl */ export interface ISyncedCustomization { /** The session customization with loading/error status. */ - readonly customization: SessionCustomization; + readonly customization: Customization; /** Local plugin directory URI, defined when the sync was successful. */ readonly pluginDir?: URI; } @@ -38,9 +38,9 @@ export interface IAgentPluginManager { readonly basePath: URI; /** - * Syncs a set of client-provided customization refs to local storage. + * Syncs a set of client-provided plugin customizations to local storage. * - * Each ref is copied to a local directory, respecting nonce-based + * Each plugin is copied to a local directory, respecting nonce-based * caching. The optional {@link progress} callback fires with the single * customization that completed or failed, allowing callers to publish * targeted incremental status updates. @@ -51,5 +51,6 @@ export interface IAgentPluginManager { * @returns Final status for every customization, with `pluginDir` * defined when the sync was successful. */ - syncCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (status: SessionCustomization) => void): Promise; + syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], progress?: (status: Customization) => void): Promise; } + diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index b60a75a0367fb..0ae473a71a593 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -18,7 +18,7 @@ import type { CompletionsParams, CompletionsResult, CreateTerminalParams, Resolv import { ProtectedResourceMetadata, type ChangesetSummary, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionCustomization, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; +import { ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -636,17 +636,19 @@ export interface IAgent { readonly onDidCustomizationsChange?: Event; /** - * Returns the host-owned customization refs this agent currently exposes. + * Returns the host-owned customizations this agent currently exposes. * * Used to publish baseline customization metadata on {@link AgentInfo}. + * Always container customizations ({@link PluginCustomization} or + * {@link DirectoryCustomization}). */ - getCustomizations?(): readonly CustomizationRef[]; + getCustomizations?(): readonly Customization[]; /** * Returns the effective customization list for a session, including * source, enablement, and loading/error status. */ - getSessionCustomizations?(session: URI): Promise; + getSessionCustomizations?(session: URI): Promise; /** * Authenticate for a specific resource. Returns true if accepted. @@ -676,7 +678,7 @@ export interface IAgent { * * The agent MAY defer a client restart until all active sessions are idle. */ - setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise; + setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise; /** * Receives client-provided tool definitions to make available in a @@ -705,8 +707,10 @@ export interface IAgent { /** * Notifies the agent that a customization has been toggled on or off. * The agent MAY restart its client before the next message is sent. + * + * @param id The opaque session-unique customization id. */ - setCustomizationEnabled(uri: string, enabled: boolean): void; + setCustomizationEnabled(id: string, enabled: boolean): void; /** Gracefully shut down all sessions. */ shutdown(): Promise; diff --git a/src/vs/platform/agentHost/common/customAgents.ts b/src/vs/platform/agentHost/common/customAgents.ts index ace9077ce4d1c..d99eda46321ac 100644 --- a/src/vs/platform/agentHost/common/customAgents.ts +++ b/src/vs/platform/agentHost/common/customAgents.ts @@ -4,34 +4,38 @@ *--------------------------------------------------------------------------------------------*/ import type { URI } from '../../../base/common/uri.js'; -import type { CustomizationAgentRef, SessionCustomization } from './state/protocol/state.js'; +import { CustomizationType, type AgentCustomization, type Customization } from './state/protocol/state.js'; /** * Computes the effective set of selectable custom agents for a session. * - * Custom agents are contributed exclusively by - * {@link SessionCustomization.agents} — only the agent host populates that - * field after parsing each customization. Disabled session customizations - * are skipped; customizations with an absent `agents` field are treated as - * "unknown" (e.g. the host has not finished parsing yet) and skipped, while - * an empty array means "no agents contributed" and is respected. + * Custom agents live as {@link CustomizationType.Agent | `Agent`} entries + * in each container customization's {@link Customization.children | `children`} + * array. Only the agent host populates `children` (after parsing the + * container). Disabled containers are skipped; containers with an absent + * `children` field are treated as "unknown" (e.g. the host has not finished + * parsing yet) and skipped, while an empty array means "no children + * contributed" and is respected. * - * The picker is keyed on the agent's stable {@link CustomizationAgentRef.uri}; + * The picker is keyed on the agent's stable {@link AgentCustomization.uri}; * duplicates within the session's customization list are coalesced. */ export function getEffectiveAgents( - sessionCustomizations: readonly SessionCustomization[] | undefined, -): readonly CustomizationAgentRef[] { - const seen = new Map(); + sessionCustomizations: readonly Customization[] | undefined, +): readonly AgentCustomization[] { + const seen = new Map(); if (sessionCustomizations) { - for (const customization of sessionCustomizations) { - if (customization.enabled === false || !customization.agents) { + for (const container of sessionCustomizations) { + if (container.enabled === false || !container.children) { continue; } - for (const agent of customization.agents) { - const key = agent.uri.toString(); + for (const child of container.children) { + if (child.type !== CustomizationType.Agent) { + continue; + } + const key = child.uri.toString(); if (!seen.has(key)) { - seen.set(key, agent); + seen.set(key, child); } } } @@ -63,10 +67,10 @@ export function agentHostAgentPickerStorageKey(resourceScheme: string): string { * sessions-layer `ISessionAgentRef` both provide URI strings. */ export function resolveAgentHostAgent( - agents: readonly CustomizationAgentRef[], + agents: readonly AgentCustomization[], sessionAgentUri: URI | string | undefined, storedAgentUri: string | undefined, -): CustomizationAgentRef | undefined { +): AgentCustomization | undefined { if (sessionAgentUri !== undefined) { const sessionStr = typeof sessionAgentUri === 'string' ? sessionAgentUri : sessionAgentUri.toString(); const match = agents.find(a => a.uri === sessionStr); @@ -76,3 +80,4 @@ export function resolveAgentHostAgent( } return storedAgentUri ? agents.find(a => a.uri === storedAgentUri) : undefined; } + diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index c925a0548ec44..d9f882145b047 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -48aaa5d +9f3ca96 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index ea1be673c0ca8..7171e65120bab 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionAgentChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionAgentChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; // ─── Root vs Session vs Terminal vs Changeset Action Unions ───────────────── @@ -68,6 +68,7 @@ export type SessionAction = | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction @@ -119,6 +120,7 @@ export type ServerSessionAction = | SessionInputRequestedAction | SessionCustomizationsChangedAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction @@ -224,6 +226,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionCustomizationUpdated]: false, + [ActionType.SessionCustomizationRemoved]: false, [ActionType.SessionTruncated]: true, [ActionType.SessionIsReadChanged]: true, [ActionType.SessionIsArchivedChanged]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index f2fd5bb682ab8..db7e3b2c328d4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -8,7 +8,7 @@ import type { ConfigSchema, ProtectedResourceMetadata } from '../common/state.js'; import type { TerminalInfo } from '../channels-terminal/state.js'; -import type { CustomizationRef } from '../channels-session/state.js'; +import type { Customization } from '../channels-session/state.js'; // ─── Root State ────────────────────────────────────────────────────────────── @@ -64,12 +64,17 @@ export interface AgentInfo { */ protectedResources?: ProtectedResourceMetadata[]; /** - * Customizations (Open Plugins) associated with this agent. + * Customizations associated with this agent. * - * Each entry is a reference to an [Open Plugins](https://open-plugins.com/) - * plugin that the agent host can activate for sessions using this agent. + * Always container customizations — + * {@link PluginCustomization | `PluginCustomization`} entries the agent + * bundles, plus {@link DirectoryCustomization | `DirectoryCustomization`} + * entries it watches in any workspace it's used with. When a session is + * created with this agent, these entries are augmented (e.g. directory + * URIs are resolved against the workspace, children are parsed) and + * propagated into the session's `customizations` list. */ - customizations?: CustomizationRef[]; + customizations?: Customization[]; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index cddd667d2307c..55ddb371ecdab 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -7,8 +7,8 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { URI, StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type SessionCustomization, type CustomizationRef, type CustomizationAgentRef, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type CustomizationStatus, type AgentSelection } from './state.js'; +import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type Customization, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type AgentSelection } from './state.js'; import type { ModelSelection } from '../channels-root/state.js'; import type { ChangesetSummary } from '../channels-changeset/state.js'; @@ -556,15 +556,17 @@ export interface SessionActiveClientToolsChangedAction { */ export interface SessionCustomizationsChangedAction { type: ActionType.SessionCustomizationsChanged; - /** Updated customization list (full replacement) */ - customizations: SessionCustomization[]; + /** Updated customization list (full replacement). */ + customizations: Customization[]; } /** - * A client toggled a customization on or off. + * A client toggled a container customization on or off. * - * The server locates the customization by `uri` in the session's - * customization list and sets its `enabled` flag. + * Targets a top-level container (plugin or directory) by `id`. Only + * containers have an `enabled` flag; children are always active when + * their container is enabled. Is a no-op when no matching container is + * found. * * @category Session Actions * @version 1 @@ -572,45 +574,45 @@ export interface SessionCustomizationsChangedAction { */ export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; - /** The URI of the customization to toggle */ - uri: URI; - /** Whether to enable or disable the customization */ + /** The id of the container to toggle. */ + id: string; + /** Whether to enable or disable the container. */ enabled: boolean; } /** - * Upserts mutable fields on a single customization. + * Upserts a top-level customization (plugin or directory). * - * Dispatched by the server to update one or more fields on a customization, - * or to add a new customization to the session, without republishing the - * entire `customizations` list. The reducer locates the existing entry by - * `customization.uri`: + * The reducer locates the existing entry by `customization.id`: * - * - If an entry exists, each provided field is assigned; absent (or - * `undefined`) fields are left unchanged. The stored `customization` - * ref is replaced with the one in the action. - * - If no entry exists, a new {@link SessionCustomization} is appended - * using the provided fields; `enabled` defaults to `false` when absent. + * - If found, the entry is replaced entirely with `customization`, + * including its `children` array. To preserve existing children, the + * host must include them on the payload. + * - If not found, the entry is appended. * * @category Session Actions * @version 1 */ export interface SessionCustomizationUpdatedAction { type: ActionType.SessionCustomizationUpdated; - /** The customization to update or insert (matched by `customization.uri`) */ - customization: CustomizationRef; - /** New enabled state (defaults to `false` on insert) */ - enabled?: boolean; - /** New loading status */ - status?: CustomizationStatus; - /** New human-readable status detail */ - statusMessage?: string; - /** - * Custom agents contributed by this customization, as resolved by the - * agent host. Populated only by the agent host. See - * {@link SessionCustomization.agents} for absent-vs-empty semantics. - */ - agents?: CustomizationAgentRef[]; + /** The customization to upsert (matched by `customization.id`). */ + customization: Customization; +} + +/** + * Removes a customization by id. + * + * Searches every container and its children for the entry. If the entry + * is a container, its children are removed with it. Is a no-op when no + * matching id is found. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCustomizationRemovedAction { + type: ActionType.SessionCustomizationRemoved; + /** The id of the customization to remove. */ + id: string; } // ─── Config Actions ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index af8cd33dced3e..7178ccb8c965e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type SessionCustomization, type SessionInputRequest, type SessionState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type SessionInputRequest, type SessionState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -603,7 +603,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (!list) { return state; } - const idx = list.findIndex(c => c.customization.uri === action.uri); + const idx = list.findIndex(c => c.id === action.id); if (idx < 0) { return state; } @@ -614,38 +614,44 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionCustomizationUpdated: { const list = state.customizations ?? []; - const idx = list.findIndex(c => c.customization.uri === action.customization.uri); + const idx = list.findIndex(c => c.id === action.customization.id); if (idx < 0) { - const inserted: SessionCustomization = { - customization: action.customization, - enabled: action.enabled ?? false, - }; - if (action.status !== undefined) { - inserted.status = action.status; - } - if (action.statusMessage !== undefined) { - inserted.statusMessage = action.statusMessage; - } - if (action.agents !== undefined) { - inserted.agents = action.agents; - } - return { ...state, customizations: [...list, inserted] }; + return { ...state, customizations: [...list, action.customization] }; } const updated = [...list]; - const next = { ...list[idx], customization: action.customization }; - if (action.enabled !== undefined) { - next.enabled = action.enabled; - } - if (action.status !== undefined) { - next.status = action.status; + updated[idx] = action.customization; + return { ...state, customizations: updated }; + } + + case ActionType.SessionCustomizationRemoved: { + const list = state.customizations; + if (!list) { + return state; } - if (action.statusMessage !== undefined) { - next.statusMessage = action.statusMessage; + const topIdx = list.findIndex(c => c.id === action.id); + if (topIdx >= 0) { + const updated = list.slice(); + updated.splice(topIdx, 1); + return { ...state, customizations: updated }; } - if (action.agents !== undefined) { - next.agents = action.agents; + let changed = false; + const updated = list.map(container => { + const children = container.children; + if (!children) { + return container; + } + const childIdx = children.findIndex(c => c.id === action.id); + if (childIdx < 0) { + return container; + } + changed = true; + const newChildren = children.slice(); + newChildren.splice(childIdx, 1); + return { ...container, children: newChildren }; + }); + if (!changed) { + return state; } - updated[idx] = next; return { ...state, customizations: updated }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index ed598951e4e7a..2998d10f8420b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -106,12 +106,19 @@ export interface SessionState { /** Session configuration schema and current values */ config?: SessionConfigState; /** - * Server-provided customizations active in this session. + * Top-level customizations active in this session. * - * Client-provided customizations are available on - * {@link SessionActiveClient.customizations | activeClient.customizations}. + * Always container customizations — {@link PluginCustomization} or + * {@link DirectoryCustomization}. Children (agents, skills, prompts, + * rules, hooks, MCP servers) live in each container's + * {@link ContainerCustomizationBase.children | `children`} array. + * + * Client-published plugins arrive via + * {@link SessionActiveClient.customizations | `activeClient.customizations`} + * and the host propagates them into this list (typically with the + * container's `clientId` set and `children` populated). */ - customizations?: SessionCustomization[]; + customizations?: Customization[]; /** * Additional provider-specific metadata for this session. * @@ -137,8 +144,15 @@ export interface SessionActiveClient { displayName?: string; /** Tools this client provides to the session */ tools: ToolDefinition[]; - /** Customizations this client contributes to the session */ - customizations?: CustomizationRef[]; + /** + * Plugin customizations this client contributes to the session. + * + * Clients publish in [Open Plugins](https://open-plugins.com/) format + * — i.e. always container-shaped plugins. They MAY synthesize virtual + * plugins in memory and rely on the host to expand them into concrete + * children inside {@link SessionState.customizations}. + */ + customizations?: ClientPluginCustomization[]; } /** @@ -203,18 +217,17 @@ export interface SessionSummary { /** * A selected custom agent for a session. * - * The `uri` identifies a specific custom agent (matching a - * {@link CustomizationAgentRef.uri | `CustomizationAgentRef.uri`} exposed - * via the session's effective customizations). Consumers resolve the - * agent's display name by looking up `uri` in - * {@link SessionCustomization.agents | `SessionCustomization.agents`}. + * The `uri` identifies a specific custom agent (matching an + * {@link AgentCustomization.uri | `AgentCustomization.uri`} exposed via + * the session's effective customizations). Consumers resolve the agent's + * display name by looking up `uri` in the session's customization tree. * * A session with no `agent` selected uses the provider's default behavior. * * @category Session State */ export interface AgentSelection { - /** Stable agent URI (matches a {@link CustomizationAgentRef.uri}) */ + /** Stable agent URI (matches an {@link AgentCustomization.uri}). */ uri: URI; } @@ -1255,97 +1268,345 @@ export type ToolResultContent = // ─── Customization Types ───────────────────────────────────────────────────── /** - * A lightweight reference to a custom agent contributed by a customization. + * Discriminant for the kind of customization. * - * Custom agents have a single `name` (sourced from the agent file's YAML - * frontmatter, or derived from the file name); they do not have a separate - * display name. + * Top-level entries in {@link SessionState.customizations} and + * {@link AgentInfo.customizations} are always + * {@link CustomizationType.Plugin | `Plugin`} or + * {@link CustomizationType.Directory | `Directory`}; the remaining + * types appear only as children of those containers. * * @category Customization Types */ -export interface CustomizationAgentRef { - /** Stable agent URI */ - uri: URI; - /** Agent name (from frontmatter `name`, or file-derived) */ - name: string; - /** Optional short description for UI preview (from frontmatter `description`) */ - description?: string; +export const enum CustomizationType { + Plugin = 'plugin', + Directory = 'directory', + Agent = 'agent', + Skill = 'skill', + Prompt = 'prompt', + Rule = 'rule', + Hook = 'hook', + McpServer = 'mcpServer', } /** - * A reference to an [Open Plugins](https://open-plugins.com/) plugin. + * Customization types that appear as children of a + * {@link PluginCustomization} or {@link DirectoryCustomization}. * - * This is intentionally thin — AHP specifies plugin identity and metadata - * but not implementation details, which are defined by the Open Plugins spec. + * @category Customization Types + */ +export type ChildCustomizationType = + | CustomizationType.Agent + | CustomizationType.Skill + | CustomizationType.Prompt + | CustomizationType.Rule + | CustomizationType.Hook + | CustomizationType.McpServer; + +/** + * Fields shared by every customization variant. * * @category Customization Types */ -export interface CustomizationRef { - /** Plugin URI (e.g. an HTTPS URL or marketplace identifier) */ +interface CustomizationBase { + /** + * Session-unique opaque identifier. Used by every action that targets a + * specific customization. Minted by whoever publishes the customization + * (typically the agent host). + */ + id: string; + /** + * Source URI for this customization. A plugin URL, a file URI, or a + * directory URI. + * + * For declarations that live inside a larger file — e.g. an MCP + * server declared inline in a `plugins.json` manifest — `uri` points + * to the containing file and {@link CustomizationBase.range | `range`} + * narrows it to the declaration's span. + */ uri: URI; - /** Human-readable name */ - displayName: string; - /** Description of what the plugin provides */ - description?: string; - /** Icons for the plugin */ + /** Human-readable name. */ + name: string; + /** Icons for UI display. */ icons?: Icon[]; /** - * Opaque version token for this customization. - * - * Clients SHOULD include a nonce with every customization they provide. - * Consumers can compare nonces to detect whether a customization has - * changed since it was last seen, avoiding redundant reloads or copies. + * Optional span within {@link CustomizationBase.uri | `uri`} when this + * customization is a subset of a larger file (for example, one entry + * in an inline `mcpServers` block of a `plugins.json` manifest). + * Absent when the customization covers the whole resource. */ - nonce?: string; + range?: TextRange; } /** - * Loading status for a server-managed customization. + * Discriminant values for {@link CustomizationLoadState}. * * @category Customization Types */ -export const enum CustomizationStatus { - /** Plugin is being loaded */ +export const enum CustomizationLoadStatus { Loading = 'loading', - /** Plugin is fully operational */ Loaded = 'loaded', - /** Plugin partially loaded but has warnings */ Degraded = 'degraded', - /** Plugin was unable to load */ Error = 'error', } /** - * A customization active in a session. + * Container is being loaded by the host. + * + * @category Customization Types + */ +export interface CustomizationLoadingState { + kind: CustomizationLoadStatus.Loading; +} + +/** + * Container loaded successfully. + * + * @category Customization Types + */ +export interface CustomizationLoadedState { + kind: CustomizationLoadStatus.Loaded; +} + +/** + * Container partially loaded but has warnings. + * + * @category Customization Types + */ +export interface CustomizationDegradedState { + kind: CustomizationLoadStatus.Degraded; + /** Human-readable description of the warning. */ + message: string; +} + +/** + * Container failed to load. + * + * @category Customization Types + */ +export interface CustomizationErrorState { + kind: CustomizationLoadStatus.Error; + /** Human-readable error message. */ + message: string; +} + +/** + * Discriminated load state for a container customization + * ({@link PluginCustomization} or {@link DirectoryCustomization}). + * + * @category Customization Types + */ +export type CustomizationLoadState = + | CustomizationLoadingState + | CustomizationLoadedState + | CustomizationDegradedState + | CustomizationErrorState; + +/** + * Fields shared by container customizations. * * @category Customization Types */ -export interface SessionCustomization { - /** The plugin this customization refers to */ - customization: CustomizationRef; - /** Whether this customization is currently enabled */ +interface ContainerCustomizationBase extends CustomizationBase { + /** Whether this container is currently enabled. */ enabled: boolean; /** - * The `clientId` of the client that contributed this customization. - * Absent for server-provided customizations. + * `clientId` of the client that contributed this container. Absent for + * server-originated entries. */ clientId?: string; - /** Server-reported loading status */ - status?: CustomizationStatus; /** - * Human-readable status detail (e.g. error message or degradation warning). + * Host-reported load state. Absent means the host has not yet reported + * a load state for this container. */ - statusMessage?: string; + load?: CustomizationLoadState; /** - * Custom agents contributed by this customization, as resolved by the - * agent host after parsing the customization. + * Children discovered inside this container. * - * Consumers MUST treat an absent field as "unknown" (e.g. the host has - * not finished parsing the customization yet). An empty array means the - * host parsed the customization and it contributes no agents. - * - * Clients are not authoritative here: only the agent host populates - * this field. + * Absent means the host has not parsed this container yet. An empty + * array means the host parsed the container and it contributes + * nothing. */ - agents?: CustomizationAgentRef[]; + children?: ChildCustomization[]; +} + +/** + * An [Open Plugins](https://open-plugins.com/) plugin. + * + * @category Customization Types + */ +export interface PluginCustomization extends ContainerCustomizationBase { + type: CustomizationType.Plugin; +} + +/** + * A {@link PluginCustomization} as published by a client. Extends the + * server-facing shape with an opaque `nonce` so the host can detect when + * the client's view of a plugin has changed and re-parse only as needed. + * + * Clients SHOULD include a `nonce`. Server-side fields like + * {@link ContainerCustomizationBase.children | `children`} and + * {@link ContainerCustomizationBase.load | `load`} are typically left + * absent on publication and populated by the host when the resolved + * plugin appears in {@link SessionState.customizations}. + * + * @category Customization Types + */ +export interface ClientPluginCustomization extends PluginCustomization { + /** Opaque version token used by the host to detect changes. */ + nonce?: string; } + +/** + * A directory the host watches for this session. + * + * Presence in the customization list signals that the host may discover + * customizations from this directory. When `writable` is `true`, clients + * MAY persist new customizations into the directory using + * [`resourceWrite`](/reference/commands#resourcewrite); the host will + * then surface the resulting child via the customization actions. + * + * The directory may not yet exist on disk. + * + * @category Customization Types + */ +export interface DirectoryCustomization extends ContainerCustomizationBase { + type: CustomizationType.Directory; + /** Which child customization type this directory holds. */ + contents: ChildCustomizationType; + /** Whether clients may write into this directory. */ + writable: boolean; +} + +/** + * A custom agent contributed by a plugin or directory. + * + * Mirrors the [Open Plugins agent](https://open-plugins.com/agent-builders/components/agents) + * format: a markdown file with YAML frontmatter, where the body is the + * agent's system prompt. + * + * @category Customization Types + */ +export interface AgentCustomization extends CustomizationBase { + type: CustomizationType.Agent; + /** + * Short description of what the agent specializes in and when to + * invoke it. Sourced from the agent file's frontmatter `description`. + */ + description?: string; +} + +/** + * A skill contributed by a plugin or directory. + * + * Covers both [Open Plugins skill formats](https://open-plugins.com/agent-builders/components/skills) + * — the `skills/` directory layout (one subdirectory per skill, each with + * a `SKILL.md`) and the flatter `commands/` directory of slash-command + * skills. + * + * @category Customization Types + */ +export interface SkillCustomization extends CustomizationBase { + type: CustomizationType.Skill; + /** + * Short description used for help text and auto-invocation matching. + * Sourced from the skill's frontmatter `description`. + */ + description?: string; + /** + * When `true`, only the user can invoke this skill — the agent will not + * auto-invoke it. Sourced from the command skill's frontmatter + * `disable-model-invocation` flag. + */ + disableModelInvocation?: boolean; +} + +/** + * A prompt contributed by a plugin or directory. + * + * @category Customization Types + */ +export interface PromptCustomization extends CustomizationBase { + type: CustomizationType.Prompt; + /** Short description of what the prompt does. */ + description?: string; +} + +/** + * A rule contributed by a plugin or directory. + * + * Mirrors the [Open Plugins rule](https://open-plugins.com/agent-builders/components/rules) + * format: a markdown file (e.g. `.mdc`) whose body is injected into + * context while the rule is active. This type also covers tool-specific + * "instruction" formats (e.g. VS Code Copilot's + * `.github/instructions/*.md`), which differ only in naming — they + * share the same semantics of `description`, optional always-on + * activation, and optional glob scoping. + * + * @category Customization Types + */ +export interface RuleCustomization extends CustomizationBase { + type: CustomizationType.Rule; + /** + * Description of what the rule enforces. + */ + description?: string; + /** + * When `true`, the rule is always active (subject to `globs` if any). + * When `false` or absent, the agent or user decides whether to apply + * the rule. + */ + alwaysApply?: boolean; + /** + * Glob patterns the rule applies to. When present, the rule is only + * active for matching files. + */ + globs?: string[]; +} + +/** + * A hook manifest contributed by a plugin or directory. + * + * @category Customization Types + */ +export interface HookCustomization extends CustomizationBase { + type: CustomizationType.Hook; +} + +/** + * An MCP manifest contributed by a plugin or directory. + * + * When the server is declared inline in the containing plugin manifest, + * `uri` points at the manifest file and + * {@link CustomizationBase.range | `range`} narrows it to the + * declaration's span. + * + * @category Customization Types + */ +export interface McpServerCustomization extends CustomizationBase { + type: CustomizationType.McpServer; +} + +/** + * Child customizations that live inside a {@link PluginCustomization} or + * {@link DirectoryCustomization}. + * + * @category Customization Types + */ +export type ChildCustomization = + | AgentCustomization + | SkillCustomization + | PromptCustomization + | RuleCustomization + | HookCustomization + | McpServerCustomization; + +/** + * A top-level customization active in a session. Always a container + * ({@link PluginCustomization} or {@link DirectoryCustomization}); the + * remaining customization types appear inside the container's + * {@link ContainerCustomizationBase.children | `children`} array. + * + * @category Customization Types + */ +export type Customization = PluginCustomization | DirectoryCustomization; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 00e506da06a62..1b7fd8715392e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,7 +10,7 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionTurnStartedAction, SessionDeltaAction, SessionResponsePartAction, SessionToolCallStartAction, SessionToolCallDeltaAction, SessionToolCallReadyAction, SessionToolCallConfirmedAction, SessionToolCallCompleteAction, SessionToolCallResultConfirmedAction, SessionToolCallContentChangedAction, SessionTurnCompleteAction, SessionTurnCancelledAction, SessionErrorAction, SessionTitleChangedAction, SessionUsageAction, SessionReasoningAction, SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, SessionActiveClientChangedAction, SessionActiveClientToolsChangedAction, SessionPendingMessageSetAction, SessionPendingMessageRemovedAction, SessionQueuedMessagesReorderedAction, SessionInputRequestedAction, SessionInputAnswerChangedAction, SessionInputCompletedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionTruncatedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionTurnStartedAction, SessionDeltaAction, SessionResponsePartAction, SessionToolCallStartAction, SessionToolCallDeltaAction, SessionToolCallReadyAction, SessionToolCallConfirmedAction, SessionToolCallCompleteAction, SessionToolCallResultConfirmedAction, SessionToolCallContentChangedAction, SessionTurnCompleteAction, SessionTurnCancelledAction, SessionErrorAction, SessionTitleChangedAction, SessionUsageAction, SessionReasoningAction, SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, SessionActiveClientChangedAction, SessionActiveClientToolsChangedAction, SessionPendingMessageSetAction, SessionPendingMessageRemovedAction, SessionQueuedMessagesReorderedAction, SessionInputRequestedAction, SessionInputAnswerChangedAction, SessionInputCompletedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionTruncatedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetOperationsChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -58,6 +58,7 @@ export const enum ActionType { SessionCustomizationsChanged = 'session/customizationsChanged', SessionCustomizationToggled = 'session/customizationToggled', SessionCustomizationUpdated = 'session/customizationUpdated', + SessionCustomizationRemoved = 'session/customizationRemoved', SessionTruncated = 'session/truncated', SessionIsReadChanged = 'session/isReadChanged', SessionIsArchivedChanged = 'session/isArchivedChanged', @@ -155,6 +156,7 @@ export type StateAction = | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 90598453611fb..cfb323089761d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -90,6 +90,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionCustomizationsChanged]: '0.1.0', [ActionType.SessionCustomizationToggled]: '0.1.0', [ActionType.SessionCustomizationUpdated]: '0.1.0', + [ActionType.SessionCustomizationRemoved]: '0.2.0', [ActionType.SessionTruncated]: '0.1.0', [ActionType.SessionIsReadChanged]: '0.1.0', [ActionType.SessionIsArchivedChanged]: '0.1.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 036bc3ebf1ab8..cf1d2a7e4a2a8 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -21,6 +21,7 @@ import { type RootState, type SessionState, type SessionSummary, + type TextRange, type ToolCallCancelledState, type ToolCallCompletedState, type ToolCallResult, @@ -53,7 +54,24 @@ export { type FileEdit as ISessionFileDiff, type ModelSelection, type AgentSelection, - type CustomizationAgentRef, + type AgentCustomization, + type Customization, + type PluginCustomization, + type DirectoryCustomization, + type ClientPluginCustomization, + type ChildCustomization, + type SkillCustomization, + type PromptCustomization, + type RuleCustomization, + type HookCustomization, + type McpServerCustomization, + type CustomizationLoadState, + type CustomizationLoadingState, + type CustomizationLoadedState, + type CustomizationDegradedState, + type CustomizationErrorState, + CustomizationLoadStatus, + CustomizationType, type SessionModelInfo, type SessionState, type SessionSummary, @@ -70,8 +88,6 @@ export { type ToolCallState, type ToolCallStreamingState, type ToolDefinition, - type CustomizationRef, - type SessionCustomization, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type ToolResultContent, type ToolResultFileEditContent, @@ -91,7 +107,6 @@ export { type ChangesetState, type ChangesetFile, type ChangesetOperation, - CustomizationStatus, MessageAttachmentKind, PendingMessageKind, PolicyState, @@ -161,6 +176,25 @@ export function isAhpRootChannel(uri: string): boolean { } } +/** + * Mints a session-unique opaque id for a customization, derived from its + * source URI and (when present) its `range` within the source. Plugins MAY + * declare multiple children (e.g. MCP servers, hooks) inside the same + * manifest file; including the range disambiguates them without an extra + * mapping table. + * + * The range is appended as a reserved `#range=` query-style suffix; any + * existing `#` in the URI is percent-encoded first so a source URI that + * already contains a fragment cannot collide with a ranged id. + */ +export function customizationId(uri: string, range?: TextRange): string { + if (!range) { + return uri; + } + const safeUri = uri.replace(/#/g, '%23'); + return `${safeUri}#range=${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`; +} + // ---- VS Code-specific derived types ----------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index 6d0b0b4a47e22..bb6e1269a5a5f 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -14,16 +14,16 @@ import { toAgentClientUri } from '../common/agentClientUri.js'; import type { IAgent } from '../common/agentService.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; +import { CustomizationLoadStatus, type ClientPluginCustomization, type Customization, type CustomizationLoadState } from '../common/state/sessionState.js'; import { parsePlugin, type INamedPluginResource } from '../../agentPlugins/common/pluginParsers.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; import { extractLeadingSlashToken } from './agentHostSlashCompletion.js'; interface ISkillCustomizationCandidate { - readonly customization: CustomizationRef; - readonly enabled: boolean; + readonly customization: Customization; + readonly nonce?: string; readonly clientId?: string; - readonly status?: CustomizationStatus; + readonly load?: CustomizationLoadState; } interface ISkillCompletionMetadata { @@ -80,7 +80,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const skillBySlashName = new Map(); for (const candidate of candidates) { const pluginRoot = this._resolvePluginRoot(candidate); - const cacheKey = this._cacheKey(agent.id, pluginRoot, candidate.customization.nonce); + const cacheKey = this._cacheKey(agent.id, pluginRoot, candidate.nonce); reachableCacheKeys.add(cacheKey); const skills = await this._getCachedSkills(agent.id, cacheKey, pluginRoot); @@ -116,7 +116,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge } private async _getCandidates(agent: IAgent, session: URI): Promise { - let sessionCustomizations: readonly SessionCustomization[] = []; + let sessionCustomizations: readonly Customization[] = []; if (agent.getSessionCustomizations) { try { sessionCustomizations = await agent.getSessionCustomizations(session); @@ -128,12 +128,13 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const seen = new Set(); const candidates: ISkillCustomizationCandidate[] = []; for (const item of sessionCustomizations) { - seen.add(item.customization.uri); + seen.add(item.uri); + const nonce = (item as ClientPluginCustomization).nonce; candidates.push({ - customization: item.customization, - enabled: item.enabled, + customization: item, + ...(nonce !== undefined ? { nonce } : {}), ...(item.clientId !== undefined ? { clientId: item.clientId } : {}), - ...(item.status !== undefined ? { status: item.status } : {}), + ...(item.load !== undefined ? { load: item.load } : {}), }); } @@ -142,10 +143,10 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge if (seen.has(customization.uri)) { continue; } - candidates.push({ customization, enabled: true }); + candidates.push({ customization }); } - return candidates.filter(candidate => candidate.enabled && candidate.status !== CustomizationStatus.Loading && candidate.status !== CustomizationStatus.Error); + return candidates.filter(candidate => candidate.customization.enabled && candidate.load?.kind !== CustomizationLoadStatus.Loading && candidate.load?.kind !== CustomizationLoadStatus.Error); } private _watchAgent(agent: IAgent): void { diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 0e272769a9fa0..eca2380d985cb 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; +import { CustomizationLoadStatus, type ClientPluginCustomization, type Customization } from '../common/state/sessionState.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; const DEFAULT_MAX_PLUGINS = 20; @@ -67,8 +67,8 @@ export class AgentPluginManager implements IAgentPluginManager { async syncCustomizations( clientId: string, - customizations: CustomizationRef[], - progress?: (status: SessionCustomization) => void, + customizations: ClientPluginCustomization[], + progress?: (status: Customization) => void, ): Promise { await this._ensureCacheLoaded(); @@ -77,13 +77,13 @@ export class AgentPluginManager implements IAgentPluginManager { this._sequencer.queue(ref.uri, async (): Promise => { try { const pluginDir = await this._syncPlugin(clientId, ref); - const customization = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; + const customization: Customization = { ...ref, load: { kind: CustomizationLoadStatus.Loaded } }; progress?.(customization); return { customization, pluginDir }; } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`); - const customization = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; + const customization: Customization = { ...ref, load: { kind: CustomizationLoadStatus.Error, message } }; progress?.(customization); return { customization }; } @@ -99,7 +99,7 @@ export class AgentPluginManager implements IAgentPluginManager { * Syncs a single plugin to local storage. Skips the copy when the * nonce matches the cached value. Returns the local directory URI. */ - private async _syncPlugin(clientId: string, ref: CustomizationRef): Promise { + private async _syncPlugin(clientId: string, ref: ClientPluginCustomization): Promise { const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId); const key = this._keyForUri(ref.uri); const destDir = URI.joinPath(this._basePath, key); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3ec2080b8dae6..3e5dabdd3202c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -838,7 +838,7 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionCustomizationToggled: { const agent = this._options.getAgent(channel); - agent?.setCustomizationEnabled?.(action.uri, action.enabled); + agent?.setCustomizationEnabled?.(action.id, action.enabled); break; } case ActionType.SessionIsReadChanged: { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 9399f1091e091..e488182512aa9 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -17,16 +17,17 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { PolicyState, ProtectedResourceMetadata, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { CustomizationRef, isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; @@ -141,6 +142,9 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _onDidCustomizationsChange = this._register(new Emitter()); + readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; + private readonly _models = observableValue(this, []); readonly models: IObservable = this._models; @@ -224,6 +228,7 @@ export class ClaudeAgent extends Disposable implements IAgent { @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, ) { super(); this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id); @@ -356,6 +361,7 @@ export class ClaudeAgent extends Disposable implements IAgent { config.workingDirectory, project, config.model, + config.agent, config.config, new PendingRequestRegistry(), permissionMode, @@ -364,6 +370,7 @@ export class ClaudeAgent extends Disposable implements IAgent { ); const entry = new ClaudeSessionEntry(session); entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); this._sessions.set(sessionId, entry); return { @@ -468,6 +475,7 @@ export class ClaudeAgent extends Disposable implements IAgent { workingDirectory, project, overlay.model, + overlay.agent, undefined, new PendingRequestRegistry(), permissionMode, @@ -476,6 +484,7 @@ export class ClaudeAgent extends Disposable implements IAgent { ); const entry = new ClaudeSessionEntry(session); entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); this._sessions.set(sessionId, entry); const canUseTool: NonNullable = (toolName, input, options) => @@ -882,6 +891,26 @@ export class ClaudeAgent extends Disposable implements IAgent { }); } + /** + * Switch (or clear with `undefined`) the selected custom agent for an + * existing session. Mirrors {@link changeModel}: session owns its + * provisional/runtime branching and metadata write + * (see {@link ClaudeAgentSession.setAgent}). For external-only + * sessions (no in-memory record), the agent is persisted directly to + * the overlay so a later resume picks it up. + */ + async changeAgent(session: URI, agent: AgentSelection | undefined): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + const sess = this._findAnySession(sessionId); + if (sess) { + await sess.setAgent(agent); + } else { + await this._metadataStore.write(session, { agent: agent ?? null }); + } + }); + } + setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { const sessionId = AgentSession.id(session); this._logService.info(`[Claude:${sessionId}] setClientTools clientId=${clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); @@ -908,12 +937,71 @@ export class ClaudeAgent extends Disposable implements IAgent { entry?.session.completeClientToolCall(toolCallId, result); } - setClientCustomizations(_session: URI, _clientId: string, _customizations: CustomizationRef[]): Promise { - throw new Error('TODO: Phase 11'); + async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { + const sessionId = AgentSession.id(session); + const sess = this._findAnySession(sessionId); + if (!sess) { + this._logService.warn(`[Claude:${sessionId}] setClientCustomizations: session not found`); + return []; + } + // Run inside the session sequencer so that a fire-and-forget + // `setClientCustomizations` from `AgentSideEffects` cannot race + // ahead of a first `sendMessage`: if `sendMessage` is already + // queued, the sync runs first or queues behind it; either way + // the materialize call reads the most recently adopted plugin + // set, never an empty one mid-sync. + return this._sessionSequencer.queue(sessionId, async () => { + const synced = await this._pluginManager.syncCustomizations( + clientId, + customizations, + status => this._fireCustomizationUpdated(session, { customization: status }), + ); + sess.adoptClientCustomizations(synced); + return synced; + }); + } + + /** + * Project a per-item sync result onto a `SessionCustomizationUpdated` + * action and emit it on {@link onDidSessionProgress}. Lets the workbench + * flip each row to `Loaded` / `Error` as the underlying + * {@link IAgentPluginManager.syncCustomizations} resolves it. + */ + private _fireCustomizationUpdated(session: URI, item: ISyncedCustomization): void { + this._onDidSessionProgress.fire({ + kind: 'action', + session, + action: { + type: ActionType.SessionCustomizationUpdated, + customization: item.customization, + }, + }); + } + + setCustomizationEnabled(id: string, enabled: boolean): void { + for (const entry of this._sessions.values()) { + entry.session.setClientCustomizationEnabled(id, enabled); + } + } + + getCustomizations(): readonly Customization[] { + // Provider-level customization catalogue — feeds `AgentInfo.customizations` + // on `RootAgentsChanged`. Should advertise host-configured plugin refs + // (the equivalent of Copilot's `agentHost.customizations` setting). + // Claude has no such surface today; returning `[]` is correct rather + // than aggregating client-pushed refs (those live on + // `activeClient.customizations` per session). + // + // TODO: when host-level customizations become a real concept for the + // agent host, lift `PluginController` out of `copilot/copilotAgent.ts` + // into a shared service so both providers consume the same configured + // host customization list rather than each maintaining their own. + return []; } - setCustomizationEnabled(_uri: string, _enabled: boolean): void { - throw new Error('TODO: Phase 11'); + async getSessionCustomizations(session: URI): Promise { + const sess = this._findAnySession(AgentSession.id(session)); + return sess ? await sess.getSessionCustomizations() : []; } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 33e2a8733074b..02f425207afc1 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -12,23 +12,27 @@ import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { ClaudeRuntimeEffortLevel, clampEffortForRuntime, resolveClaudeEffort } from '../../common/claudeModelConfig.js'; import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { PendingMessage, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import type { ToolCallResult } from '../../common/state/sessionState.js'; +import { PendingMessage, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import type { Customization, ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; import { ClaudeSessionMetadataStore } from './claudeSessionMetadataStore.js'; import { convertToolCallResult } from './clientTools/claudeClientToolResult.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; +import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; +import { projectSessionCustomizations } from './customizations/claudeSessionCustomizationsProjector.js'; +import { ClaudeSdkCustomizationBundler } from './customizations/claudeSdkCustomizationBundler.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle } from './claudeProxyService.js'; -import { ClaudeSdkPipeline, IRematerializer } from './claudeSdkPipeline.js'; +import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js'; import { SubagentRegistry } from './claudeSubagentRegistry.js'; import { ClaudePermissionKind } from './claudeToolDisplay.js'; @@ -68,9 +72,20 @@ function resolveCurrentPermissionMode( export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; + private _sdkBundler: ClaudeSdkCustomizationBundler | undefined; /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; + /** + * Pre-materialize custom-agent selection. Mutable; flows into + * `Options.agent` (resolved to the SDK agent name) on materialize + * and on every rematerializer call. Mid-session changes via + * {@link setAgent} flip {@link clientCustomizationsDiff} dirty so the + * next `send()` rebinds and the new agent reaches the SDK on the + * rebuilt `Query`. The SDK's `Options.agent` is captured at startup + * — there is no runtime control-plane equivalent. + */ + private _provisionalAgent: AgentSelection | undefined; /** Pre-materialize `IAgentCreateSessionConfig.config` bag. Read at materialize time. */ readonly provisionalConfig: Record | undefined; /** Resolved project metadata captured at create time (if any). */ @@ -89,6 +104,7 @@ export class ClaudeAgentSession extends Disposable { workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, + agent: AgentSelection | undefined, config: Record | undefined, pendingClientToolCalls: PendingRequestRegistry, permissionModeFallback: ClaudePermissionMode, @@ -102,6 +118,7 @@ export class ClaudeAgentSession extends Disposable { workingDirectory, project, model, + agent, config, new AbortController(), pendingClientToolCalls, @@ -145,6 +162,22 @@ export class ClaudeAgentSession extends Disposable { */ readonly toolDiff: SessionClientToolsDiff; + /** + * Phase 11 — per-session **client-pushed** synced customization + * snapshot + enablement map. Owns the workbench-supplied + * {@link ISyncedCustomization} list, the per-URI enablement bits, + * and the dirty flag drained at the next {@link send} pre-flight. + * Exists from `createProvisional` onward so client-side reads / + * toggles work uniformly before and after materialize. + * + * Server-side (SDK-discovered) customizations are NOT stored here + * — they're fetched on demand from the live `Query` in + * {@link getSessionCustomizations}. + * + * See {@link SessionClientCustomizationsDiff}. + */ + readonly clientCustomizationsDiff: SessionClientCustomizationsDiff = this._register(new SessionClientCustomizationsDiff()); + private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress: Event = this._onDidSessionProgress.event; @@ -154,6 +187,7 @@ export class ClaudeAgentSession extends Disposable { readonly workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, + agent: AgentSelection | undefined, config: Record | undefined, abortController: AbortController, private readonly _pendingClientToolCalls: PendingRequestRegistry, @@ -169,9 +203,11 @@ export class ClaudeAgentSession extends Disposable { super(); this.project = project; this._provisionalModel = model; + this._provisionalAgent = agent; this.provisionalConfig = config; this.abortController = abortController; this.toolDiff = this._register(toolDiff); + this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); } /** @@ -208,6 +244,8 @@ export class ClaudeAgentSession extends Disposable { canUseTool: ctx.canUseTool, isResume: ctx.isResume, mcpServers, + plugins: this.clientCustomizationsDiff.consume(), + agent: this._resolveAgentName(this._provisionalAgent), }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -243,6 +281,15 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(s))); this._pipeline = pipeline; + // On-disk Open Plugin bundle for SDK-discovered customizations. + // The bundle directory is content-addressed by the SDK snapshot + // hash and lives under the plugin manager's user-data tree; + // disposing the bundler does NOT delete the on-disk tree (kept + // as a warm cache across sessions on the same workingDirectory). + this._sdkBundler = this._register(this._instantiationService.createInstance( + ClaudeSdkCustomizationBundler, + this.workingDirectory, + )); // Seed the pipeline's bijective config cache so a rebuild re-applies // the user's last-chosen model / effort without losing the picker @@ -294,19 +341,28 @@ export class ClaudeAgentSession extends Disposable { canUseTool: ctx.canUseTool, isResume: true, mcpServers: rebuildMcp, + plugins: this.clientCustomizationsDiff.consume(), + agent: this._resolveAgentName(this._provisionalAgent), }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), ); - this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild`); + this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild agent=${rebuildOptions.agent ?? '(none)'}`); const rebuildWarm = await this._sdkService.startup({ options: rebuildOptions }); return { warm: rebuildWarm, abortController: rebuildAbort }; } catch (err) { this.toolDiff.markDirty(); + this.clientCustomizationsDiff.markDirty(); throw err; } }); + + // Surface the SDK-resolved customization tier to the workbench. + // Pre-materialize, getSessionCustomizations returns only the + // client-pushed slice; firing here prompts the workbench to refetch + // and pick up the bundled `Discovered in Claude` entry. + this._onDidCustomizationsChange.fire(); } /** True once {@link materialize} has installed the SDK pipeline. */ @@ -342,10 +398,13 @@ export class ClaudeAgentSession extends Disposable { * Send a user prompt. Performs the per-turn pre-flight before * yielding to the pipeline: * - * - If {@link toolDiff} reports the workbench client-tool snapshot has - * diverged from what the live `Query` was started with, yield-restart - * so the SDK picks up the new `Options.mcpServers`. The rebind itself - * re-applies the live `permissionMode` via the rematerializer. + * - If {@link toolDiff} or {@link clientCustomizationsDiff} reports the + * live `Query` is out of sync with the workbench's view, yield-restart + * so the SDK picks up the new `Options.mcpServers` / `Options.plugins`. + * `Query.reloadPlugins()` cannot help here — the SDK's plugin URI set + * is captured at startup, so any add / remove / nonce-bump must go + * through a full rebuild. The rebind itself re-applies the live + * `permissionMode` via the rematerializer. * - Otherwise forward the live `permissionMode` to the bound `Query` so * a `SessionConfigChanged` action that arrived between turns wins. * The pipeline's bijective cache dedupes a no-op `setPermissionMode`, @@ -357,14 +416,32 @@ export class ClaudeAgentSession extends Disposable { */ async send(prompt: SDKUserMessage, turnId: string): Promise { const pipeline = this._requirePipeline(); - if (this.toolDiff.hasDifference) { - await this.rebindForClientTools(); + if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifference) { + await this._rebindForSyncedState(); } else { await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this.sessionUri, this._permissionModeFallback)); } return pipeline.send(prompt, turnId); } + /** + * Single yield-restart that covers both client-tool and + * customization divergence in one trip. Drains the parked + * client-tool MCP handlers (same as the original tool-only + * rebind), then triggers the pipeline rebind — the rematerializer + * reads `toolDiff` and `clientCustomizationsDiff.consume()` while + * building the new `Options`, so the bit on each diff clears in + * lockstep with the SDK actually receiving the new values. Fires + * `_onDidCustomizationsChange` afterwards so the workbench + * refetches `getSessionCustomizations` and picks up any newly + * resolved server-side entries from the rebuilt `Query`. + */ + private async _rebindForSyncedState(): Promise { + this._pendingClientToolCalls.rejectAll(new CancellationError()); + await this._requirePipeline().rebindForRestart(); + this._onDidCustomizationsChange.fire(); + } + /** * Cancel the in-flight SDK turn. Mirrors the production reference; * see {@link ClaudeSdkPipeline.abort}. Also denies any parked @@ -409,6 +486,61 @@ export class ClaudeAgentSession extends Disposable { await this._metadataStore.write(this.sessionUri, { model }); } + /** + * Pre-materialize custom-agent selection accessor. + */ + get provisionalAgent(): AgentSelection | undefined { return this._provisionalAgent; } + + /** + * Change (or clear with `undefined`) the selected custom agent for this + * session. The SDK captures `Options.agent` at startup with no + * working runtime control (`applyFlagSettings({ agent })` exists on + * the SDK surface but doesn't actually swap the live agent), so + * post-materialize calls flip {@link clientCustomizationsDiff} + * dirty and the next `send()` pre-flight rebinds with the new agent + * baked into the rebuilt `Query`. Persisted to the per-session + * metadata overlay so a resume picks up the choice. + */ + async setAgent(agent: AgentSelection | undefined): Promise { + if (this._provisionalAgent === agent) { + return; + } + this._provisionalAgent = agent; + if (this._pipeline) { + // Force a rebind on the next send(); the SDK has no working + // runtime hook to swap the agent in place. + this.clientCustomizationsDiff.markDirty(); + } + await this._metadataStore.write(this.sessionUri, { agent: agent ?? null }); + } + + /** + * Resolve an {@link AgentSelection} URI to the SDK agent name the + * SDK expects on `Options.agent`. Every custom agent the picker can + * surface for a Claude session comes from the SDK side + * ({@link ClaudeSdkCustomizationBundler} populates + * `SessionCustomization.agents` from `Query.supportedAgents()`), + * pointing at on-disk `.../agents/.md` files we wrote + * ourselves, so the name is the file basename. + * + * Returns `undefined` when no agent is selected (or the URI doesn't + * resolve to a known agent file) so the SDK falls back to its default + * (no `--agent` flag). + */ + private _resolveAgentName(agent: AgentSelection | undefined): string | undefined { + if (!agent) { + return undefined; + } + const uri = URI.parse(agent.uri); + const basename = uri.path.split('/').pop() ?? ''; + const name = basename.replace(/\.md$/i, ''); + if (!name) { + this._logService.warn(`[Claude:${this.sessionId}] _resolveAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; + } + /** * Inject a steering message. Builds the `priority: 'now'` * {@link SDKUserMessage} and hands it to the pipeline; the pipeline @@ -536,16 +668,97 @@ export class ClaudeAgentSession extends Disposable { } /** - * Drive a yield-restart so the SDK picks up the new client-tool set on - * its next user request. Cancels any in-flight client-tool MCP handlers - * and resets the bridge state before swapping the {@link Query}; the - * agent's rematerializer rebuilds `Options.mcpServers` from - * {@link toolDiff} during the rebind and pins `applied` to the - * build-time snapshot via {@link SessionClientToolsDiff.build}. + * Drive a yield-restart so the SDK picks up the new client-tool set + * on its next user request. Public entry point for callers that need + * to force a tool-only rebind; internal pre-flight goes through + * {@link _rebindForSyncedState}. */ async rebindForClientTools(): Promise { - this._pendingClientToolCalls.rejectAll(new CancellationError()); - await this._requirePipeline().rebindForRestart(); + await this._rebindForSyncedState(); + } + + // #endregion + + // #region Phase 11 — customizations / plugins + + /** + * Merged fire-and-forget signal that this session's customization + * surface changed. Fires from three sources: + * + * 1. Client-side writes (`adoptClientCustomizations` / + * `setClientCustomizationEnabled`) — via the + * {@link SessionClientCustomizationsDiff} observable wired up in the + * constructor. + * 2. Materialize completes — surfaces the server-side + * (SDK-discovered) tier to the workbench for the first time. + * 3. The send() pre-flight rebind completes — the rebuilt SDK's + * resolved set may have changed. + * + * Drives a workbench refetch of {@link getSessionCustomizations}. + * Does NOT itself trigger any SDK action — the dirty bit on + * {@link SessionClientCustomizationsDiff} drives plugin rebinds, + * and only flips on client-side writes. + */ + private readonly _onDidCustomizationsChange = this._register(new Emitter()); + readonly onDidCustomizationsChange: Event = this._onDidCustomizationsChange.event; + + /** + * Adopt the result of a global {@link IAgentPluginManager.syncCustomizations} + * pass (**client-pushed** path). The agent owns the manager (it's + * a process-wide singleton with a shared on-disk cache) and pushes + * the resulting snapshot down here. Flips the client-side dirty bit + * so the next {@link send} pre-flight reloads SDK plugins. + */ + adoptClientCustomizations(synced: readonly ISyncedCustomization[]): void { + this.clientCustomizationsDiff.model.setSyncedCustomizations(synced); + } + + /** Toggle a **client-pushed** customization on/off for this session. */ + setClientCustomizationEnabled(id: string, enabled: boolean): void { + this.clientCustomizationsDiff.model.setEnabled(id, enabled); + } + + /** + * Snapshot of the **client-pushed** customizations on this session. + * Does NOT include server-side (SDK-discovered) entries — use + * {@link getSessionCustomizations} for the merged view. + */ + getClientCustomizations(): readonly ISyncedCustomization[] { + return this.clientCustomizationsDiff.model.state.get().synced; + } + + /** + * Project the union of (a) **client-pushed** customizations and + * (b) the **server-side** (SDK-discovered) view (commands / agents + * / MCP servers, including those the SDK discovered on its own + * from `~/.claude/**`) onto the protocol's + * {@link Customization} surface, with the per-id enablement + * overlay applied to client-pushed entries. + * + * Pre-materialize sessions return only the client-pushed projection + * — the SDK side has no Query to query yet. A failure to read the + * SDK snapshot is warn-logged and the client-pushed projection is + * still returned, so a transient SDK hiccup doesn't blank the UI. + */ + async getSessionCustomizations(): Promise { + const { synced, enablement } = this.clientCustomizationsDiff.model.state.get(); + let bundled: Customization | undefined; + if (this._pipeline && this._sdkBundler) { + let sdk: ISdkResolvedCustomizations | undefined; + try { + sdk = await this._pipeline.snapshotResolvedCustomizations(); + } catch (err) { + this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); + } + if (sdk) { + try { + bundled = await this._sdkBundler.bundle(sdk); + } catch (err) { + this._logService.warn(`[Claude:${this.sessionId}] SDK bundle failed`, err); + } + } + } + return projectSessionCustomizations(synced, enablement, bundled); } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts index 0f8f93167ae96..efed231ceca03 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts @@ -32,6 +32,23 @@ export interface IBuildOptionsInput { readonly canUseTool: NonNullable; readonly isResume: boolean; readonly mcpServers: Record | undefined; + /** + * Local plugin directories to load at SDK startup. Projected onto + * `Options.plugins` as `{ type: 'local', path }`. Omitted from the + * returned options entirely when empty so the SDK keeps its default + * (no plugins). Built per-session from + * {@link SessionClientCustomizationsDiff.consume}. + */ + readonly plugins?: readonly URI[]; + /** + * Resolved SDK agent name (matches a key in `Options.agents`, or an + * agent loaded from `~/.claude/agents/**`). Projected onto + * `Options.agent` — the SDK's `--agent` flag. The plugin URI captured + * at startup is the only path the SDK consults, so any `changeAgent` + * after materialize triggers a yield-restart through the rematerializer. + * Omit when no custom agent is selected (SDK default behavior). + */ + readonly agent?: string; } /** @@ -88,6 +105,10 @@ export async function buildOptions( ? { resume: input.sessionId } : { sessionId: input.sessionId }), ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + ...(input.plugins && input.plugins.length > 0 + ? { plugins: input.plugins.map(p => ({ type: 'local' as const, path: p.fsPath })) } + : {}), + ...(input.agent ? { agent: input.agent } : {}), settingSources: ['user', 'project', 'local'], settings: { env: settingsEnv }, systemPrompt: { type: 'preset', preset: 'claude_code' }, diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index be1b9d32109e5..5ab525211393e 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionMode, Query, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentInfo, McpServerStatus, PermissionMode, Query, SDKUserMessage, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -60,7 +60,61 @@ export interface IRematerializer { * Disposing the pipeline aborts the controller (terminating the SDK * subprocess per `sdk.d.ts:982`) and async-disposes the WarmQuery. */ +/** + * Snapshot of everything the SDK has currently resolved for this + * session. Returned by {@link ClaudeSdkPipeline.snapshotResolvedCustomizations}. + */ +export interface ISdkResolvedCustomizations { + readonly commands: readonly SlashCommand[]; + readonly agents: readonly AgentInfo[]; + readonly mcpServers: readonly McpServerStatus[]; +} + export class ClaudeSdkPipeline extends Disposable { + /** + * Phase 11 — hot-swap the SDK's plugin set in place via + * `Query.reloadPlugins()`. Commands / agents / mcpServers added or + * removed by the new plugin set become visible to the SDK + * immediately, without a session restart. Throws if the query is + * not yet bound (session not materialized). + */ + async reloadPlugins(): Promise { + const query = await this._ensureQueryBound(); + await query.reloadPlugins(); + } + + /** + * Phase 11 — snapshot the SDK's currently-resolved customization + * surface (slash commands / skills, subagents, MCP servers). This + * is the SDK's view of "what does this session actually have + * access to right now" — covers everything the SDK loaded itself + * (`~/.claude/**`, `.claude/agents/`, `settings.json` MCP) AND + * anything we fed in via `Options.plugins`. The host overlays + * client-side enablement separately. + */ + async snapshotResolvedCustomizations(): Promise { + const query = await this._ensureQueryBound(); + const [commands, agents, mcpServers] = await Promise.all([ + query.supportedCommands(), + query.supportedAgents(), + query.mcpServerStatus(), + ]); + return { commands, agents, mcpServers }; + } + + /** + * Bind the SDK Query if the previous one has unwound (e.g. after a + * terminal result message). Mirrors the lazy bind in {@link send} + * so pre-flight helpers can call into the SDK without first having + * to issue a user prompt. + */ + private async _ensureQueryBound(): Promise { + if (!this._query) { + this._query = this._warm.query(this._queue.iterable); + await this._replayCurrentConfig(); + } + return this._query; + } private _query: Query | undefined; private _warm: WarmQuery; diff --git a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts index 39f35cde7896a..048bb0322cc0c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ClaudePermissionMode, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { AgentProvider, AgentSession, IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import type { ModelSelection } from '../../common/state/protocol/state.js'; +import type { AgentSelection, ModelSelection } from '../../common/state/protocol/state.js'; /** * Read view of Claude's per-session DB overlay. SDK-supplied fields @@ -19,16 +19,19 @@ export interface IClaudeSessionOverlay { readonly customizationDirectory?: URI; readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; + readonly agent?: AgentSelection; } /** * Write view: any subset of the overlay fields. Fields left `undefined` - * are not touched (only-write-on-defined semantics). + * are not touched (only-write-on-defined semantics). Pass `null` for + * `agent` to clear a previously persisted selection. */ export interface IClaudeSessionOverlayUpdate { readonly customizationDirectory?: URI; readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; + readonly agent?: AgentSelection | null; } /** @@ -54,6 +57,7 @@ export class ClaudeSessionMetadataStore { private static readonly KEY_CUSTOMIZATION_DIRECTORY = 'claude.customizationDirectory'; private static readonly KEY_MODEL = 'claude.model'; private static readonly KEY_PERMISSION_MODE = 'claude.permissionMode'; + private static readonly KEY_AGENT = 'claude.agent'; constructor( private readonly _provider: AgentProvider, @@ -80,6 +84,12 @@ export class ClaudeSessionMetadataStore { if (fields.permissionMode) { work.push(db.setMetadata(ClaudeSessionMetadataStore.KEY_PERMISSION_MODE, fields.permissionMode)); } + if (fields.agent !== undefined) { + work.push(db.setMetadata( + ClaudeSessionMetadataStore.KEY_AGENT, + fields.agent === null ? '' : JSON.stringify({ uri: fields.agent.uri }), + )); + } await Promise.all(work); } finally { dbRef.dispose(); @@ -99,15 +109,17 @@ export class ClaudeSessionMetadataStore { return {}; } try { - const [customizationDirectoryRaw, modelRaw, permissionModeRaw] = await Promise.all([ + const [customizationDirectoryRaw, modelRaw, permissionModeRaw, agentRaw] = await Promise.all([ ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_CUSTOMIZATION_DIRECTORY), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_MODEL), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_PERMISSION_MODE), + ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_AGENT), ]); return { customizationDirectory: customizationDirectoryRaw ? URI.parse(customizationDirectoryRaw) : undefined, model: parseModelSelection(modelRaw), permissionMode: narrowClaudePermissionMode(permissionModeRaw), + agent: parseAgentSelection(agentRaw), }; } finally { ref.dispose(); @@ -128,10 +140,26 @@ export class ClaudeSessionMetadataStore { workingDirectory: entry.cwd ? URI.file(entry.cwd) : undefined, customizationDirectory: overlay.customizationDirectory, model: overlay.model, + agent: overlay.agent, }; } } +function parseAgentSelection(raw: string | undefined): AgentSelection | undefined { + if (!raw) { + return undefined; + } + try { + const value: { uri?: unknown } = JSON.parse(raw); + if (value && typeof value === 'object' && typeof value.uri === 'string') { + return { uri: value.uri }; + } + } catch { + // fall through + } + return undefined; +} + function serializeModelSelection(model: ModelSelection): string { return JSON.stringify(model); } diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts new file mode 100644 index 0000000000000..de04e169794e7 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { hash } from '../../../../../base/common/hash.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { IAgentPluginManager } from '../../../common/agentPluginManager.js'; +import { CustomizationLoadStatus, CustomizationType, customizationId, type AgentCustomization, type Customization, type SkillCustomization } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; + +const PLUGIN_NAME = 'claude-discovered'; +const DISPLAY_NAME = localize('claude.discovered.displayName', "Discovered in Claude"); +const DISCOVERED_DIR = 'claude-discovered'; + +/** + * The Claude SDK's built-in default agent. Hidden from the picker: + * selecting it would be equivalent to "no selection" since the SDK + * uses it as the fallback when `Options.agent` is omitted. + */ +export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; + +/** + * Bundles the Claude SDK's currently-resolved customization view + * (commands + agents from `Query.supportedCommands()` / + * `supportedAgents()` / `mcpServerStatus()`) into a synthetic on-disk + * [Open Plugin](https://open-plugins.com/) layout, so the workbench's + * plugin expander can scan it and emit per-type child items + * (`PromptsType.agent` / `PromptsType.skill` / `PromptsType.prompt`). + * + * Returns a single {@link Customization} (plugin container) whose `name` + * is `"Discovered in Claude"` and whose URI points at the on-disk bundle + * root. The `children` array is populated directly from the SDK snapshot + * so the agent picker can list Claude-native agents and skills without + * waiting on filesystem expansion. + * + * The directory is namespaced by a hash of the working directory so + * concurrent sessions on different folders don't collide. Repeated + * {@link bundle} calls with the same SDK snapshot reuse the prior + * bundle (nonce match) and skip the rewrite. + */ +export class ClaudeSdkCustomizationBundler extends Disposable { + + private readonly _rootUri: URI; + private _lastNonce: string | undefined; + + constructor( + workingDirectory: URI, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginManager pluginManager: IAgentPluginManager, + ) { + super(); + const authority = `claude-${hash(workingDirectory.toString())}`; + this._rootUri = URI.joinPath(pluginManager.basePath, DISCOVERED_DIR, authority); + } + + async bundle(snapshot: ISdkResolvedCustomizations): Promise { + if (snapshot.commands.length === 0 && snapshot.agents.length === 0) { + return undefined; + } + + const hashParts: string[] = []; + for (const agent of snapshot.agents) { + hashParts.push(`agent:${agent.name}\n${agent.description}\n${agent.model ?? ''}`); + } + for (const cmd of snapshot.commands) { + hashParts.push(`command:${cmd.name}\n${cmd.description}\n${cmd.argumentHint ?? ''}`); + } + hashParts.sort(); + const nonce = String(hash(hashParts.join('\n'))); + + if (this._lastNonce !== nonce) { + try { + await this._fileService.del(this._rootUri, { recursive: true }); + } catch { + // First bundle — directory may not exist. + } + // Vendor-neutral manifest path per Open Plugins spec + // (`.plugin/plugin.json`). `name` is the only required field + // and must be lowercase alphanumeric / `-` / `.` only. + const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); + await this._fileService.writeFile(manifestUri, VSBuffer.fromString(JSON.stringify({ + name: PLUGIN_NAME, + description: 'Customizations discovered by the Claude agent', + }, null, '\t'))); + + for (const agent of snapshot.agents) { + const fileUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`); + await this._fileService.writeFile(fileUri, VSBuffer.fromString(agentMarkdown(agent.name, agent.description))); + } + for (const cmd of snapshot.commands) { + // Treat Claude slash commands as skills: each becomes its + // own `skills//SKILL.md` subdirectory per the Agent + // Skills format. Conceptually they're the same thing — + // a named, model-invocable capability — and the workbench + // buckets them under skills. + const dirName = safeName(cmd.name); + const fileUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md'); + await this._fileService.writeFile(fileUri, VSBuffer.fromString(skillMarkdown(dirName, cmd.description, cmd.argumentHint))); + } + this._lastNonce = nonce; + } + + // Hide the SDK's built-in default agent — see + // {@link CLAUDE_SDK_DEFAULT_AGENT_NAME} for the full rationale. + // `uri` is the on-disk path of the file we just wrote — the + // workbench's customization harness reads it via `parseNew` to + // hydrate `ICustomAgent`, so a synthetic identity scheme would + // fail to parse and the agents would never reach the picker. + const agentChildren: AgentCustomization[] = snapshot.agents + .filter(agent => agent.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME) + .map(agent => { + const agentUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`).toString(); + return { + type: CustomizationType.Agent, + id: customizationId(agentUri), + uri: agentUri, + name: agent.name, + description: agent.description, + }; + }); + const skillChildren: SkillCustomization[] = snapshot.commands.map(cmd => { + const dirName = safeName(cmd.name); + const skillUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md').toString(); + return { + type: CustomizationType.Skill, + id: customizationId(skillUri), + uri: skillUri, + name: dirName, + description: cmd.description, + }; + }); + + const rootUriString = this._rootUri.toString(); + return { + type: CustomizationType.Plugin, + id: customizationId(rootUriString), + uri: rootUriString, + name: DISPLAY_NAME, + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [...agentChildren, ...skillChildren], + }; + } +} + +function safeName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'unnamed'; +} + +/** + * Open Plugins agent frontmatter: `name` (1-64 chars, kebab-case) and + * `description` (max 1024 chars). The body is the agent's system + * prompt; the SDK doesn't surface it, so we leave the body empty. + */ +function agentMarkdown(name: string, description: string): string { + return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n`; +} + +/** + * Agent Skills `SKILL.md` frontmatter: `name` (MUST match the + * containing directory name) and `description`. The SDK's + * `argumentHint` is rendered as a `$ARGUMENTS` usage hint in the body. + */ +function skillMarkdown(name: string, description: string, argumentHint: string | undefined): string { + const body = argumentHint ? `\nUsage: \`${argumentHint}\`\n` : ''; + return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n${body}`; +} + +function yamlString(s: string): string { + // Quote always; escape backslashes and double quotes. Single-line: drop newlines. + const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' '); + return `"${escaped}"`; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts new file mode 100644 index 0000000000000..de48a23c1d070 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { equals as arraysEqual } from '../../../../../base/common/arrays.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { autorun, derivedOpts, IObservable, ISettableObservable, observableValueOpts } from '../../../../../base/common/observable.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; + +/** + * Per-session **client-pushed** customization snapshot + enablement + * map. "Client" here means the workbench client that called + * `setClientCustomizations` / `setCustomizationEnabled` — server-side + * (SDK-discovered) customizations live separately and are never + * stored in this model. The two fields travel as one value so + * consumers can read both with a single `.get()` and so that an + * update to either is observed as a single change. + */ +export interface ISessionCustomizationsState { + readonly synced: readonly ISyncedCustomization[]; + readonly enablement: ReadonlyMap; +} + +const INITIAL_STATE: ISessionCustomizationsState = { synced: [], enablement: new Map() }; + +/** + * Pure observable state holder for the **client-pushed** + * {@link ISyncedCustomization} list and the per-customization + * enablement map. Exposes a derived `enabledPluginPaths` view used + * to project `Options.plugins` at materialize / rematerialize. + * + * Server-side (SDK-discovered) customizations are NOT in scope here + * — they're fetched on demand from the live `Query` in + * `getSessionCustomizations` and never written into this + * model. + * + * `state` dedupes structurally-equivalent writes: a re-send of the + * same `(synced, enablement)` pair does NOT fire downstream + * subscribers. Knows nothing about diffing or the SDK — pair with + * {@link SessionClientCustomizationsDiff} to track "has the client-pushed + * snapshot changed since the last successful SDK plugin reload". + */ +export class SessionClientCustomizationsModel { + + private readonly _state: ISettableObservable = observableValueOpts( + { owner: this, equalsFn: stateEqual }, + INITIAL_STATE, + ); + readonly state: IObservable = this._state; + + /** + * Resolved local plugin paths for the currently enabled + * **client-pushed** customizations. Customizations without a + * `pluginDir` (still loading or failed sync) are excluded. + * Default enablement is `true` — an absent entry counts as + * enabled. Server-side customizations contribute nothing here. + */ + readonly enabledPluginPaths: IObservable = derivedOpts( + { owner: this, equalsFn: (a, b) => arraysEqual(a, b, (x, y) => x.toString() === y.toString()) }, + reader => { + const s = this._state.read(reader); + const paths: URI[] = []; + for (const synced of s.synced) { + if (!synced.pluginDir) { + continue; + } + if (s.enablement.get(synced.customization.id) === false) { + continue; + } + paths.push(synced.pluginDir); + } + return paths; + }, + ); + + /** Replace the client-pushed customization snapshot for this session. */ + setSyncedCustomizations(synced: readonly ISyncedCustomization[]): void { + const cur = this._state.get(); + this._state.set({ synced, enablement: cur.enablement }, undefined); + } + + /** Toggle a client-pushed customization on/off for this session. */ + setEnabled(id: string, enabled: boolean): void { + const cur = this._state.get(); + const current = cur.enablement.get(id); + if (current === enabled || (enabled && current === undefined)) { + return; + } + const next = new Map(cur.enablement); + if (enabled) { + next.delete(id); + } else { + next.set(id, false); + } + this._state.set({ synced: cur.synced, enablement: next }, undefined); + } +} + +/** + * Tracks "has the **client-pushed** customization snapshot changed + * since the SDK was last (re)started against it?". Subscribes to + * {@link SessionClientCustomizationsModel.state}, with the state + * observable's equalsFn structurally comparing the meaningful + * fields (URI list, enablement, nonce, status, user-visible + * metadata). Same race semantics as `SessionClientToolsDiff`: a + * write that lands during an in-flight rebind re-flips dirty via + * the autorun, so callers don't need to snapshot-compare. + * + * Why state and not just `enabledPluginPaths`: the SDK's + * `reloadPlugins()` is parameterless — the plugin URI set is + * captured into `Options.plugins` at startup and is otherwise + * immutable. Any meaningful change (new plugin, toggle, content + * refresh via nonce, metadata refresh) therefore requires the + * yield-restart path to take effect, so we treat every state + * change as SDK-relevant. + * + * Server-side (SDK-discovered) customizations are NOT tracked + * here — the SDK manages its own discovery lifecycle, and + * changes to server-side data flow to the workbench via separate + * event fires (post-materialize, post-rebind). + * + * On rebind throw the bit is left set — the SDK is still running + * with the previous plugin set, so the next sendMessage should + * retry. + */ +export class SessionClientCustomizationsDiff extends Disposable { + + readonly model: SessionClientCustomizationsModel = new SessionClientCustomizationsModel(); + + private _dirty = false; + // `autorun` invokes its callback once at registration for dependency + // tracking. Skip that initial run so a brand-new diff doesn't + // report dirty before any mutation has happened. + private _ignoreNextFire = true; + + /** + * Outward fire-and-forget signal that the underlying state + * changed. Derived from the observable so external listeners + * (e.g. agent-level event aggregation) don't have to subscribe to + * the observable directly. + */ + readonly onDidChange: Event = Event.fromObservableLight(this.model.state); + + constructor() { + super(); + this._register(autorun(reader => { + this.model.state.read(reader); + if (this._ignoreNextFire) { + this._ignoreNextFire = false; + return; + } + this._dirty = true; + })); + } + + get hasDifference(): boolean { + return this._dirty; + } + + /** + * Read the resolved enabled plugin paths and mark the current + * snapshot as applied. A subsequent write that changes any + * meaningful field re-flips dirty via the autorun. If the caller's + * downstream work (e.g. SDK rebind) fails, call {@link markDirty} + * to surface the stale state. + */ + consume(): readonly URI[] { + const paths = this.model.enabledPluginPaths.get(); + this._dirty = false; + return paths; + } + + /** + * Force the dirty bit on. Use when async work that followed + * {@link consume} failed and the SDK is therefore still on the + * previous plugin set. + */ + markDirty(): void { + this._dirty = true; + } +} + +function stateEqual(a: ISessionCustomizationsState, b: ISessionCustomizationsState): boolean { + return syncedListEqual(a.synced, b.synced) && enablementEqual(a.enablement, b.enablement); +} + +function syncedListEqual(a: readonly ISyncedCustomization[], b: readonly ISyncedCustomization[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + const ai = a[i].customization; + const bi = b[i].customization; + if (ai.id !== bi.id) { + return false; + } + if (ai.uri.toString() !== bi.uri.toString()) { + return false; + } + if ((ai as { nonce?: string }).nonce !== (bi as { nonce?: string }).nonce) { + return false; + } + if (ai.name !== bi.name) { + return false; + } + if (ai.enabled !== bi.enabled) { + return false; + } + if (ai.load?.kind !== bi.load?.kind) { + return false; + } + if (loadMessageOf(ai.load) !== loadMessageOf(bi.load)) { + return false; + } + if (!childrenEqual(ai.children, bi.children)) { + return false; + } + if (a[i].pluginDir?.toString() !== b[i].pluginDir?.toString()) { + return false; + } + } + return true; +} + +function loadMessageOf(load: { kind: string; message?: string } | undefined): string | undefined { + return load && load.message ? load.message : undefined; +} + +function childrenEqual(a: readonly { id: string; name: string }[] | undefined, b: readonly { id: string; name: string }[] | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id || a[i].name !== b[i].name) { + return false; + } + } + return true; +} + +function enablementEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a.size !== b.size) { + return false; + } + for (const [k, v] of a) { + if (b.get(k) !== v) { + return false; + } + } + return true; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts new file mode 100644 index 0000000000000..6daba64717891 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import type { Customization } from '../../../common/state/protocol/state.js'; + +/** + * Project the union of (a) client-pushed customizations and + * (b) the on-disk discovery bundle (server-provided) onto the + * protocol's {@link Customization} surface. + * + * Client-pushed entries get the per-id enablement overlay applied + * (`enablement.get(id) ?? customization.enabled`). The discovery + * bundle is surfaced verbatim — it is a single synthetic plugin URI + * pointing at an on-disk Open Plugin layout (`agents/`, `skills/`, + * `commands/`, `rules/`) the workbench's plugin expander scans to + * emit per-type child items. Per-file enablement happens + * workbench-side; we surface only the bundle URI. + */ +export function projectSessionCustomizations( + synced: readonly ISyncedCustomization[], + enablement: ReadonlyMap, + discovered: Customization | undefined, +): readonly Customization[] { + const result: Customization[] = []; + + for (const item of synced) { + const enabled = enablement.get(item.customization.id) ?? item.customization.enabled; + result.push({ ...item.customization, enabled }); + } + + if (discovered) { + result.push(discovered); + } + + return result; +} + diff --git a/src/vs/platform/agentHost/node/claude/phase11-plan.md b/src/vs/platform/agentHost/node/claude/phase11-plan.md new file mode 100644 index 0000000000000..15828c38dcfe9 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase11-plan.md @@ -0,0 +1,168 @@ +# Phase 11 — Customizations / Plugins (full surface) + +> Generated by super-planner. Source: `roadmap.md` (phase 11). +> Last updated: 2026-05-21 after council-plan (GPT-5.5 + Claude Opus 4.6; +> Codex returned a network error — 2-agent council) and structural +> revision: per-session ownership mirroring `clientTools/`. No +> provider-wide controller. + +**Status:** done. Implemented and merged via PR #318113. **The original `reloadPlugins`-as-hot-swap design was abandoned during council review** — the SDK's `Query.reloadPlugins()` is parameterless and cannot change the plugin URI set after startup. Any client-pushed customization change therefore triggers a yield-restart through the same rematerializer path used for client-tool changes; `Query.reloadPlugins()` is no longer called from production. See [Decisions](#decisions) for the revised contract. + +## Goal + +Wire customizations (skills + plugins) end-to-end for the Claude provider so the workbench can sync, enable, and toggle customizations against a live Claude session with the same `IAgent` surface CopilotAgent already implements. The session must accept the synced plugin paths at startup and pick up any later add / remove / toggle / nonce-bump via a yield-restart before the next turn. Customization state lives on the session, mirroring how client tools are held. + +## Scope + +**In scope** + +- Replace the `TODO: Phase 11` throws in `ClaudeAgent.setClientCustomizations` and `ClaudeAgent.setCustomizationEnabled`. +- Add the outbound surface: `onDidCustomizationsChange`, `getCustomizations()`, `getSessionCustomizations(session)`. +- Add a `plugins` input on `IBuildOptionsInput` so `buildOptions()` projects it into `Options.plugins`. +- **All per-session customization state — synced set, enablement map, resolved plugin paths, dirty bit — owned by `ClaudeAgentSession`**, in a new `customizations/` folder parallel to `clientTools/`. +- Drain pending plugin changes at the session's `send()` pre-flight via the existing `rebindForRestart()` path. (Original plan called for `Query.reloadPlugins()`; see [Decisions](#decisions).) +- Server-side (SDK-discovered) customizations surfaced as a single "Discovered in Claude" Open Plugins-conformant on-disk bundle written by `ClaudeSdkCustomizationBundler`. +- Mid-turn-race semantics: a sync or toggle that lands during a live `sendMessage` is visible on the next yield boundary, never mutating the current turn. + +**Out of scope** + +- `IAgent` API shape changes — the protocol surface is fixed. +- A provider-wide `ClaudePluginController` class. Per the session-owned-state intent, each session is responsible for its own customizations, mirroring how `SessionClientToolsDiff` works for client tools. +- Workbench / customizations editor UI changes. +- SDK `initializationResult()` as a probe for `available_plugins` — useful diagnostic, not required for correctness; defer. +- Session-discovered (on-disk) customizations (Copilot's `SessionDiscoveredEntry` pattern). Future phase. +- Hot-swapping customizations mid-turn. SDK has no mid-turn `reloadPlugins` contract; the yield boundary is the only safe point. + +## Prerequisites + +- Phase 10 yield-restart primitive (`SessionClientToolsDiff` + `ClaudeAgentSession.rebindForClientTools()` + `ClaudeSdkPipeline.rebindForRestart()`) is the structural reference for both the per-session ownership pattern AND the restart fallback path. +- Phase 10.5 collapsed materialization into `ClaudeAgentSession.materialize(ctx)`; the session is the natural owner of per-session customization state and the place to drain pending reloads. +- `IAgentPluginManager` DI singleton exists at `src/vs/platform/agentHost/common/agentPluginManager.ts` and ships `syncCustomizations(clientId, customizations, progress?)`. Injected into the session via DI (not the agent), same way `IClaudeAgentSdkService` is injected into the session today. +- SDK `Query.reloadPlugins()`, `Query.supportedCommands()`, and `Options.plugins` are available on the pinned `@anthropic-ai/claude-agent-sdk` version. +- Workspace E2E skills available: `launch` (Playwright/CDP), `code-oss-logs`, `chat-customizations-editor`. + +## Approach + +Mirror the `clientTools/` pattern exactly. Add a sibling `customizations/` folder under `node/claude/` containing two collaborators — `SessionClientCustomizationsDiff` (parallel to `SessionClientToolsDiff`) and `ClaudeSdkCustomizationBundler` (server-side discovery projection) — plus the `projectSessionCustomizations` pure function that merges both tiers. `SessionClientCustomizationsDiff` lives on `ClaudeAgentSession` and owns: the session's synced `ISyncedCustomization[]` snapshot, the per-URI enablement map, the resolved enabled local plugin paths, and a `dirty` reload flag. `ClaudeAgent` becomes a thin dispatcher: `setClientCustomizations` looks up the session and calls `session.adoptClientCustomizations(synced)`; `setCustomizationEnabled` walks `_sessions` and calls `session.setClientCustomizationEnabled(uri, enabled)` on each. The session does the real work — update its own state, flip its dirty bit, and fire its `onDidCustomizationsChange` event. `claudeSdkOptions.buildOptions` gains a `plugins` field; the session passes its own resolved paths into materialize and the rematerializer via `customizationsDiff.consume()`. A client-pushed customization change flips the session's dirty bit; the next `send()` pre-flight runs `rebindForRestart()` (same path the tool diff uses) when either diff is dirty. The agent-level `_sessionSequencer` also wraps `setClientCustomizations` so a fire-and-forget call from `AgentSideEffects` cannot race a first `sendMessage`. + +## Steps + +1. **Add `SessionCustomizationsDiff` collaborator under a new `customizations/` folder.** Mirrors `clientTools/claudeSessionClientToolsModel.ts` shape. Owns: `syncedCustomizations: readonly ISyncedCustomization[]`, `enablement: Map` (per-session), `resolveEnabledPluginPaths(): readonly URI[]` (derived view used at materialize/reload), `consume(): readonly URI[]` (clears dirty + returns current paths), and `onDidChange: Event` fired on any state mutation. + - Files: `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsModel.ts` (new) + - Depends on: none + - Done when: model compiles with no SDK dependencies; unit tests cover sync update, enablement toggle, dirty lifecycle, event firing. + +2. **Add `plugins` field to `IBuildOptionsInput` and project to `Options.plugins`.** Update materialize + rematerializer call sites in the session to pass `session.customizationsDiff.consume()` so plugins are baked into SDK options on both fresh startup and yield-restart. + - Files: `src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts`, `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 1 + - Done when: with non-empty enabled paths, `buildOptions` returns `Options.plugins` as `Record`; empty omits the field; both materialize and the rematerializer closure pass the current snapshot. + +3. **Add the SDK-resolved snapshot helper on the pipeline.** Narrow public method `snapshotResolvedCustomizations(): Promise<{commands, agents, mcpServers}>` that reads the live `Query`'s `supportedCommands` / `supportedAgents` / `mcpServerStatus` in parallel. Used by `getSessionCustomizations` to surface the server-side tier; not used to drive the dirty bit. (Original plan also called for a `reloadPluginsAndSnapshot` helper; cut after council review proved `reloadPlugins` couldn't change the plugin set.) + - Files: `src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts` + - Depends on: none + - Done when: a unit test against the fake `Query` verifies the snapshot returns the three SDK fields verbatim. + +4. **Wire customizations onto the session.** Inject `IAgentPluginManager` into `ClaudeAgentSession` via DI (same pattern as `IClaudeAgentSdkService` after Phase 10.5). Add public methods: + - `setClientCustomizations(clientId, customizations, progress?): Promise` — calls `pluginManager.syncCustomizations`, updates `customizationsDiff.syncedCustomizations`, returns the synced set. + - `setCustomizationEnabled(uri, enabled): void` — flips the per-session enablement bit; the diff recomputes enabled paths and flips dirty. + - `getCustomizations(): readonly ISyncedCustomization[]` — returns `customizationsDiff.syncedCustomizations`. + - `onDidCustomizationsChange: Event` — forwards `customizationsDiff.onDidChange`. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 1 + - Done when: session compiles with the new DI dep; unit test exercises sync to enable to toggle round-trip directly on a session instance. + +5. **`ClaudeAgent` becomes a thin dispatcher.** Replace the four customization stubs with one-line delegations: + - `setClientCustomizations(session, clientId, customizations)` looks up the session via `_findAnySession(id)`, constructs a progress callback that forwards each item via `_onDidSessionProgress.fire(SessionCustomizationUpdated)`, and awaits `session.setClientCustomizations(clientId, customizations, progress)`. + - `setCustomizationEnabled(uri, enabled)` walks `_sessions.values()` and calls `entry.session.setCustomizationEnabled(uri, enabled)`. + - `getCustomizations()` aggregates the union of `session.getCustomizations()` across `_sessions.values()`, deduped by URI. + - `getSessionCustomizations(session)` returns `_findAnySession(id)?.getCustomizations() ?? []` (works for provisional sessions since the diff exists from `createProvisional` onward). + - `onDidCustomizationsChange` is an aggregated event fired when any session's `onDidCustomizationsChange` fires (subscribed via the existing `entry.addDisposable(...)` pattern that already wires per-session signals). + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts` + - Depends on: step 4 + - Done when: all four outbound and two inbound surfaces work end-to-end; the `Phase 11` throws are gone. + +6. **Drain pending plugin changes at `send()` pre-flight.** Inside `ClaudeAgentSession.send()`, AFTER the existing `toolDiff.hasDifference` check, collapse to `if (toolDiff.hasDifference || clientCustomizationsDiff.hasDifference) await rebindForRestart()`. The rematerializer reads `clientCustomizationsDiff.consume()` while building the new `Options`, so the new plugin URI set lands in `Options.plugins` of the rebuilt `Query`. (Original plan called for `reloadPlugins`-then-compare-tools-then-maybe-restart; abandoned because `Query.reloadPlugins()` is parameterless and cannot change the plugin URI set.) + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: steps 1, 3, 4 + - Done when: a client-pushed customization change triggers exactly one `rebindForRestart` and one new `sdk.startup` on the next `send()`; mid-turn writes don't drain into the in-flight turn. + +7. **Tests.** + - Files: `src/vs/platform/agentHost/test/node/customizations/` (new folder mirroring source layout), `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` (new describe block), `src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts` (plugins field). + - Depends on: step 6 + - Done when: model unit tests pass; agent tests cover `setClientCustomizations` action publishing, sequencer serialisation, rebind-on-customization-dirty, provisional-session resolution, mid-turn race, swallowed-SDK-snapshot fallback; bundler tests cover write layout / nonce stability / name sanitisation / namespacing / delete-on-change; options test confirms `plugins` projection. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsModel.ts` | create | Per-session synced + enablement state, parallel to `clientTools/claudeSessionClientToolsModel.ts` | +| `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` | modify | Inject `IAgentPluginManager`; own `customizationsDiff`; implement `setClientCustomizations` / `setCustomizationEnabled` / `getCustomizations` / `onDidCustomizationsChange`; drain pending reload at `send()` pre-flight; pass plugins into materialize/rematerializer | +| `src/vs/platform/agentHost/node/claude/claudeAgent.ts` | modify | Replace Phase 11 throws with thin delegations to the session; aggregate outbound surface | +| `src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts` | modify | `IBuildOptionsInput.plugins`; project to `Options.plugins` | +| `src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts` | modify | Public `snapshotResolvedCustomizations()` reading the live `Query`'s commands / agents / MCP servers in parallel | +| `src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsModel.test.ts` | create | Model unit tests | +| `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` | modify | `setClientCustomizations` action publishing; reload-no-restart vs reload-then-restart; provisional and mid-turn | +| `src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts` | modify | `plugins` projection into `Options.plugins` | +| `src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts` | modify | `snapshotResolvedCustomizations` round-trip | + +## Decisions + +- **State location.** Both synced customizations AND enablement live on `ClaudeAgentSession` via `SessionCustomizationsDiff`. No provider-wide controller class. Mirrors how client tools are held on the session. Each session is responsible for its own customizations, matching the new architectural direction. +- **`ClaudeAgent` role.** Thin dispatcher only. Looks up the target session by id and delegates. Aggregates outbound events / lists by iterating `_sessions`. The agent never holds customization state itself. +- **Folder layout.** New `src/vs/platform/agentHost/node/claude/customizations/` folder parallels the existing `clientTools/` folder. One file (`claudeSessionCustomizationsModel.ts`); add more as needs emerge. +- **Plugin-set changes are restart-required, not defer-and-coalesce.** Council review (PR #318113) verified that `Query.reloadPlugins()` in `@anthropic-ai/claude-agent-sdk` is parameterless and only re-reads from the plugin URI set captured into `Options.plugins` at startup. Any add / remove / toggle / nonce-bump therefore requires a full SDK rebuild via `rebindForRestart()`. The single dirty bit + single send() pre-flight branch is the simpler model that this constraint forces. `reloadPlugins` may return as a narrow optimisation for content-only nonce-bumps in a future phase if profiling shows it matters. +- **Tool-set divergence detection.** Not needed under the rebind-always model. Removed from the implementation. +- **Provisional sessions.** `customizationsDiff` exists from `ClaudeAgentSession.createProvisional()` onward, so `getCustomizations()` and `setClientCustomizations`/`setCustomizationEnabled` work uniformly before and after materialize. No special-case branching on `isPipelineReady`. +- **No dedicated sequencer for `setCustomizationEnabled`.** The enablement write is synchronous on the session; the SDK side effect drains inside `send()` pre-flight, which already runs under the per-session sequencer. Rapid toggles coalesce naturally — only the final enablement state matters at the next send. +- **SDK `initializationResult()` for `available_plugins`.** Not wired. `snapshotResolvedCustomizations` provides the same info on demand from the live `Query`. Deferred as a diagnostic. +- **Mid-turn-race semantics.** A sync or toggle that lands while a `sendMessage` is in flight does NOT mutate the current turn. The sync writes plugin files to disk and updates the session's diff; the toggle flips the session's enablement bit and dirty flag. The current turn keeps running with whatever plugin set the SDK already has. The next `send()` pre-flight observes the dirty bit and performs reload (or restart). Matches CONTEXT.md §M11's "no mid-turn mutation path" invariant. + +## Risks + +- **`Query.reloadPlugins()` is parameterless** — verified during council review. Drove the architectural pivot from defer-and-coalesce reload to rebind-always; see Decisions. +- **SDK version variance in `snapshotResolvedCustomizations`** — mitigated by isolating the three calls inside `claudeSdkPipeline.snapshotResolvedCustomizations()` and tolerating a thrown rejection in `getSessionCustomizations` (warn-log and fall through to the client-only projection). +- **Race: sync completes during in-flight materialize** — mitigated by routing `setClientCustomizations` through the per-session sequencer in `ClaudeAgent`, AND by reading plugin paths from `clientCustomizationsDiff.consume()` inside `buildOptions` call sites rather than capturing them earlier. +- **Per-session enablement state diverges across sessions** — accepted by design. Each session owns its own enablement; the workbench is responsible for broadcasting toggles to every session it cares about by calling `setCustomizationEnabled(uri, enabled)`, which the agent fans out to all `_sessions`. + +## Verification + +### Unit / Integration + +- Unit suite per step: + - `./scripts/test.sh --runGlob "**/agentHost/test/**/*.test.js"` +- Targeted Phase 11 cases: + - Model: sync round-trip, enable/disable toggle fires `onDidChange`, nonce-bump and metadata changes flip dirty, `enabledPluginPaths` derivation, `consume()` clears dirty. + - Options: `plugins` non-empty -> `Options.plugins` projection; empty -> field omitted. + - Pipeline: `snapshotResolvedCustomizations` returns the three SDK fields verbatim. + - Bundler: write layout / nonce stability / `safeName` sanitisation / working-directory namespacing / delete-on-change. + - Session: direct `adoptClientCustomizations` then `setClientCustomizationEnabled` then read-back via `getClientCustomizations`; mid-turn toggle does not mutate in-flight turn; `send()` pre-flight rebinds when dirty; swallowed-SDK-snapshot fallback in `getSessionCustomizations`. + - Agent: thin dispatcher correctness — `setClientCustomizations` forwards progress as `SessionCustomizationUpdated` actions and runs inside the per-session sequencer; `setCustomizationEnabled` fans out to all `_sessions`. + +### E2E + +- **Launch skill**: `launch` — Playwright/CDP automation of `./scripts/code.sh --agents`. +- **Log skill**: `code-oss-logs` — read `agenthost.log` and per-session log. +- **Customizations UI skill**: `chat-customizations-editor` — domain expert on the customizations editor surface. +- **Scenario**: + 1. Launch Code OSS with `--agents`; open a Claude session under `Local Agent Host`. + 2. From the chat-customizations editor, add a customization with a simple skill plugin; send a turn that uses the skill; confirm via `code-oss-logs` that `agenthost.log` shows `[Claude] session ...: enableFileCheckpointing=true isResume=false` followed by a successful turn that references the plugin. + 3. Disable the same customization; send another turn; confirm logs show `[Claude] session ...: resume rebuild` (any plugin-set change is a yield-restart — there is no `reloadPlugins` fast path). + 4. Add a second customization; send a turn; confirm logs show another `resume rebuild` and that the new plugin is in `Options.plugins`. + 5. Confirm dirty bit clears between turns (no spurious second rebind) and no Claude subprocess leaks (`ps aux | grep claude | grep -v grep`). + +### Manual + +- If the customization picker has UI affordances that the `launch` skill cannot reliably drive (Monaco-focus issues seen in Phase 10.5 E2E), document manual click-through with screenshots into `/tmp/code-oss-screenshots//` and confirm each scenario above. + +## Open Questions + +None. + +## References + +- Roadmap: `./roadmap.md` (Phase 11) +- Context: `./CONTEXT.md` §M6 (Customizations cluster), §M11 (hot-swap / defer-and-coalesce / restart-required taxonomy) +- Prior plan: `./phase10.5-plan.md` (per-session ownership pattern + yield-restart primitive) +- Reference extension: `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts` — `_pendingPluginReload`, `_toolsMatch`, `_setCustomizations`, `_loadPlugins`, `_destroyAndRecreateQuery` +- CopilotAgent for IAgent surface shape only: `src/vs/platform/agentHost/node/copilot/copilotAgent.ts` lines 311, 315, 998, 1026 (note: Copilot uses a provider-wide `PluginController`; Claude deliberately diverges to per-session ownership) +- E2E skills used: `launch`, `code-oss-logs`, `chat-customizations-editor` diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 7bcdc4e28b925..1253d47150851 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -97,7 +97,7 @@ Phase numbers are stable identifiers — code comments, plan files do **not** renumber. The actual landing order diverges from numeric order to unblock self-hosting sooner: -**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 14 → 15** +**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 14 → 15 → 16** Phase 13 (session restoration) is pulled forward immediately after Phase 9 because it unlocks two high-leverage capabilities: @@ -1055,46 +1055,60 @@ dispose) clean across the whole session lifecycle. Full step-by-step plan: [phase10.5-plan.md](./phase10.5-plan.md). -### Phase 11 — Customizations / plugins (full surface) +### Phase 11 — Customizations / plugins (full surface) ✅ **DONE** + +Shipped in PR #318113. Two-tier model: **Inbound (host → SDK):** -- `setClientCustomizations(clientId, customizations, progress?)` — call - `agentPluginManager.syncCustomizations` to download `CustomizationRef[]` - to local dirs, get back `ISyncedCustomization[]` with local paths. - Forward incremental results via the `progress` callback - (`agentService.ts:439`) for progressive loading UI. -- Pass the local paths as `options.plugins: [{ type: 'local', path }, ...]` - on the next `query()` call. -- **`setCustomizationEnabled(uri, enabled)` — defer-and-coalesce, NOT - restart.** Set `_pendingPluginReload`; at the next yield boundary, call - `Query.reloadPlugins()` (a cheap runtime SDK setter — bijective per - M11). `reloadPlugins` is in M11's **defer-and-coalesce** bucket, not - restart-required: the running subprocess stays up. Only when the *tool - set* implied by the new plugin list diverges from the live one do we - fall back to the **restart-required** path (yield-restart via - `resume: sessionId`); that's the narrow `_toolsMatch` case from - `claudeCodeAgent.ts`, not the default. The misnamed `_pendingRestart` - flag from the reference impl is a historical artifact — the canonical - taxonomy treats plugin reload as cheap. - -**Outbound (SDK → host) — required for Copilot parity -(`agentService.ts:399–417`):** - -- `onDidCustomizationsChange` event. -- `getCustomizations()` — return host-known customizations (synced + active). -- `getSessionCustomizations(session)` — per-session active list. -- See `copilotAgent.ts:190–205, 232–240` for the wiring pattern. - -Tests: client provides a customization → agent syncs it → next `query()` -includes the local path → SDK init message confirms the plugin loaded; -customization toggle drains via `reloadPlugins` at the next yield (no -subprocess restart) and the new plugin appears in `available_plugins`; a -tool-set diff *does* trigger yield-restart; published events fire correctly. - -Exit criteria: customization round-trip works; toggle is defer-and-coalesce -by default and restart-required only when tool sets diverge; workbench -renders Claude customizations like Copilot's. +- `setClientCustomizations(clientId, customizations, progress?)` — runs inside + the per-session sequencer (so a fire-and-forget call from `AgentSideEffects` + cannot race a first `sendMessage`). Calls + `IAgentPluginManager.syncCustomizations` to download `CustomizationRef[]` to + local dirs, forwards incremental results via the `progress` callback for + progressive loading UI, and adopts the resulting `ISyncedCustomization[]` on + the session. +- `setCustomizationEnabled(uri, enabled)` — flips the per-session enablement + bit. Drains at the next `send()` pre-flight. +- **Both writes → yield-restart, NOT in-place reload.** `Query.reloadPlugins()` + in `@anthropic-ai/claude-agent-sdk` is parameterless: it can only re-read + files at plugin paths captured into `Options.plugins` at startup, so it + cannot add a new plugin, drop a disabled one, or pick up a content refresh + via nonce bump. `send()`'s pre-flight runs a single `rebindForRestart()` + when either `toolDiff` or `clientCustomizationsDiff` is dirty; the + rematerializer reads `clientCustomizationsDiff.consume()` while building + `Options`, so the new plugin URI list lands on the rebuilt `Query`. + +**Outbound (SDK → host):** + +- `onDidCustomizationsChange` event — fires from (1) client-pushed writes via + the diff observable, (2) materialize completion (surfaces the SDK-discovered + tier for the first time), (3) pre-flight rebind completion. +- `getCustomizations()` — provider-level catalogue (host-configured); returns + `[]` for Claude today since there is no host-configured surface yet. +- `getSessionCustomizations(session)` — returns the merged projection of + client-pushed entries (with per-URI enablement overlay) plus the + SDK-discovered bundle from `ClaudeSdkCustomizationBundler`. Server-side + commands / agents / MCP servers from the live `Query` are bundled as a + single "Discovered in Claude" Open Plugins-conformant on-disk tree under + `IAgentPluginManager.basePath`, namespaced by working-directory hash and + nonce-stable across repeated bundles of the same SDK snapshot. + +**Per-session ownership.** All customization state lives on +`ClaudeAgentSession`: + +- `SessionClientCustomizationsModel` + `SessionClientCustomizationsDiff` under + `customizations/` (parallel to `clientTools/`) own the synced list, + enablement map, derived enabled plugin paths, and dirty bit. Dirty is + driven from the model state observable (widened equality covers `nonce`, + `displayName`, `description`, `statusMessage`, `agents`, `pluginDir`, + status, enablement) so same-URI content refreshes correctly flip dirty. +- `ClaudeSdkCustomizationBundler` writes the on-disk Open Plugin tree on + demand from `getSessionCustomizations`. Repeated calls with the same SDK + snapshot skip the rewrite. The tree is intentionally a cross-session warm + cache (not deleted on session dispose). + +Full step-by-step plan: [phase11-plan.md](./phase11-plan.md). ### Phase 12 — Subagents ✅ **DONE** @@ -1322,6 +1336,91 @@ Exit criteria: a fresh VS Code install can use the Claude agent without manually installing the SDK or setting any path. SDK upgrades arrive as marketplace extension updates. +### Phase 16 — Eager session materialization at create time + +**Status:** follow-up to Phase 11. Phase 11's +`getProjectedSessionCustomizations` already returns the SDK-resolved +customization tier when the pipeline is bound, but for provisional +sessions it returns only the client-pushed half. The full picture — +SDK-discovered skills (`~/.claude/skills/**`), agents (`.claude/agents/**`), +and `~/.claude/settings.json` MCP servers — only materializes after the +first `sendMessage`. Workbench UX wants the full list available +immediately on `createSession` so a draft session can show its true +capability surface before the user types. + +**Direction:** collapse the provisional/materialize split for the +non-fork `createSession` path. `createSession` synchronously +materializes (spawns the SDK subprocess, opens the proxy refcount, +runs the metadata write, fires `onDidMaterializeSession`) before +returning. + +**Why this is its own phase, not part of Phase 11.** Phase 11's +projector and SDK snapshot work stand on their own — they make +`getSessionCustomizations` correct *whenever* the pipeline is bound. +The eager-materialize change rewrites the M9 lifecycle contract, +touches the `_sessionSequencer`'s first-send branch, changes +disposable semantics for never-used sessions, and updates CONTEXT.md. +Coupling the two would inflate Phase 11's blast radius for no review +benefit; landing them serially keeps each change small. + +**Scope:** + +- `ClaudeAgent.createSession` calls `_materializeProvisional(sessionId)` + synchronously before returning. Return value's `provisional` flag is + either dropped or redefined ("no on-disk transcript yet" rather than + "no SDK" — settle in the plan). +- `_sessionSequencer`'s "first call materializes" branch in + `sendMessage` is removed; every reachable session has a live pipeline. +- `disposeSession` for a never-sent session now tears down a live + subprocess (the existing teardown handles it but is no longer free — + audit cost). +- Fork path (Phase 6.5, when it lands) already materializes synchronously + on `forkSession` return — semantics align naturally. +- CONTEXT.md M9: revise the "Provisional sessions own no SDK + resources" invariant; relax the "two-phase contract is locked" + framing; update the lifecycle tables to reflect "creation is the + materialize trigger". Phase 16 owns the doc update. +- Tests that exercise the provisional → first-send materialize race + (Phase 10.5 regression coverage, Phase 11 mid-turn toggle race) + reworked against the new contract. +- `getSessionCustomizations` for a freshly-created session now returns + the full SDK-resolved + client-pushed projection without waiting on + a send. + +**Trade-offs accepted (documented for posterity):** + +- Drafting is no longer free — every `createSession` pays a subprocess + spawn, plugin sync, proxy refcount, and metadata write. +- A draft the user cancels without sending costs the same as a session + that runs a turn (minus the actual model call). +- The two-phase model (provisional → materialized) collapses into a + single phase for non-fork creation. Fork already materializes + eagerly; this aligns the two paths. + +**Open design points** (settle in the phase plan when scheduled): + +- Does `IAgentCreateSessionResult.provisional` get dropped, or + redefined to mean "no on-disk SDK transcript yet" (true until the + first message lands and the SDK persists)? Workbench callers may + rely on the flag for deferred-notification semantics. +- `_onDidMaterializeSession` fires from inside `createSession`. The + service-layer deferred `sessionAdded` dispatch (`agentService.ts:412`) + must still see the event between the create and the visibility + window — verify ordering. +- Failure modes: if materialization throws (proxy down, SDK install + broken), does `createSession` reject? Probably yes — the user has + no usable session anyway. Today's lazy path lets the failure surface + on first `sendMessage` instead; eager surfaces it earlier, which is + arguably better UX. +- E2E coverage: a workbench scenario that creates a session and + inspects `getSessionCustomizations` *without* sending a message, + verifies the full SDK-resolved list is present. + +Exit criteria: `getSessionCustomizations(freshlyCreatedSession)` +returns the full SDK + client-pushed projection synchronously after +`createSession` resolves; M9 doc updated; Phase 10.5 / 11 race tests +reworked and green. + --- ## Open questions (to resolve as we go) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index feef26b234007..89783daf45ef2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -7,6 +7,7 @@ import { CopilotClient, ResumeSessionConfig, type CopilotClientOptions, type Ses import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -22,17 +23,17 @@ import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginP import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformRootSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type AgentSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type AgentSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type MessageAttachment, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; @@ -41,7 +42,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toCustomizationAgentRefs, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { parsedPluginsEqual, toChildCustomizations, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; @@ -245,6 +246,15 @@ export class CopilotAgent extends Disposable implements IAgent { private _clientStarting: Promise | undefined; private _githubToken: string | undefined; private readonly _sessions = this._register(new DisposableMap()); + /** + * In-flight {@link _resumeSession} promises, keyed by sessionId. Used to + * deduplicate concurrent resume requests for the same session so that + * we never construct two {@link CopilotAgentSession} entries for the + * same id — `_sessions` is a {@link DisposableMap} whose `set()` would + * dispose the in-flight first entry mid-{@link CopilotAgentSession.initializeSession}, + * leaving the second caller with a half-initialised, eventless session. + */ + private readonly _resumingSessions = new Map>(); /** * Sessions created by a client but not yet materialized into a Copilot * SDK session + worktree + on-disk metadata. Materialization is deferred @@ -333,11 +343,11 @@ export class CopilotAgent extends Disposable implements IAgent { return [GITHUB_COPILOT_PROTECTED_RESOURCE]; } - getCustomizations(): readonly CustomizationRef[] { + getCustomizations(): readonly Customization[] { return this._plugins.getConfiguredHostCustomizations(); } - async getSessionCustomizations(session: URI): Promise { + async getSessionCustomizations(session: URI): Promise { return this._plugins.getSessionCustomizationsSettled(await this._getSessionCustomizationDirectory(session)); } @@ -919,11 +929,13 @@ export class CopilotAgent extends Disposable implements IAgent { return new CopilotSessionWrapper(raw); }; - let agentSession: CopilotAgentSession; + let agentSession: CopilotAgentSession | undefined; try { agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); await agentSession.initializeSession(); + this._registerInitializedSession(sessionId, agentSession); } catch (error) { + agentSession?.dispose(); await this._removeCreatedWorktree(sessionId); throw error; } @@ -1023,7 +1035,7 @@ export class CopilotAgent extends Disposable implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { const directory = await this._getSessionCustomizationDirectory(session); return this._plugins.sync(clientId, customizations, directory, action => { this._onDidSessionProgress.fire({ kind: 'action', session, action }); @@ -1434,9 +1446,12 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Creates a {@link CopilotAgentSession}, registers it in the sessions map, - * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} - * to wire up the SDK session. + * Instantiates a {@link CopilotAgentSession} for the given session id. + * The caller is responsible for awaiting {@link CopilotAgentSession.initializeSession} + * and, on success, registering the entry in {@link _sessions}. The + * session is intentionally **not** registered here so a concurrent + * {@link _resumeSession} for the same id cannot dispose this entry mid-init + * via {@link DisposableMap.set}. */ private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, snapshot?: IActiveClientSnapshot): CopilotAgentSession { const sessionUri = AgentSession.uri(this.id, sessionId); @@ -1455,10 +1470,28 @@ export class CopilotAgent extends Disposable implements IAgent { }, ); - this._sessions.set(sessionId, agentSession); return agentSession; } + /** + * Register a freshly initialised session in `_sessions`, or — if + * shutdown has already started between init beginning and resolving — + * dispose the session and throw {@link CancellationError}. Without this + * guard an in-flight `_resumeSession` / `_materializeProvisional` whose + * `initializeSession()` resolves after `dispose()` has run would call + * `_sessions.set(...)` on a disposed `DisposableMap`, leaking the + * session and reproducing the very 'Trying to add a disposable to a + * DisposableStore that has already been disposed' warning this fix + * exists to prevent. + */ + private _registerInitializedSession(sessionId: string, agentSession: CopilotAgentSession): void { + if (this._shutdownPromise) { + agentSession.dispose(); + throw new CancellationError(); + } + this._sessions.set(sessionId, agentSession); + } + private async _destroyAndDisposeSession(sessionId: string): Promise { // Provisional sessions have no SDK session, no worktree, and no // on-disk metadata — drop the in-memory record and clean up the @@ -1518,7 +1551,23 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - protected async _resumeSession(sessionId: string): Promise { + protected _resumeSession(sessionId: string): Promise { + const existing = this._resumingSessions.get(sessionId); + if (existing) { + return existing; + } + const promise = this._doResumeSession(sessionId); + this._resumingSessions.set(sessionId, promise); + const cleanup = () => { + if (this._resumingSessions.get(sessionId) === promise) { + this._resumingSessions.delete(sessionId); + } + }; + promise.then(cleanup, cleanup); + return promise; + } + + private async _doResumeSession(sessionId: string): Promise { this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`); const client = await this._ensureClient(); @@ -1582,7 +1631,13 @@ export class CopilotAgent extends Disposable implements IAgent { }; const agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); - await agentSession.initializeSession(); + try { + await agentSession.initializeSession(); + } catch (err) { + agentSession.dispose(); + throw err; + } + this._registerInitializedSession(sessionId, agentSession); return agentSession; } @@ -1837,7 +1892,7 @@ export class CopilotAgent extends Disposable implements IAgent { } interface IResolvedCustomization { - readonly customization: SessionCustomization; + readonly customization: Customization; readonly pluginDir?: URI; readonly plugin?: IParsedPlugin; } @@ -1900,13 +1955,16 @@ class SessionDiscoveredEntry extends Disposable { const pluginDir = URI.parse(bundleResult.ref.uri); const plugin = await this._resolvePlugin(pluginDir); this._resolved = { - customization: { - customization: bundleResult.ref, - enabled: true, - status: plugin ? CustomizationStatus.Loaded : CustomizationStatus.Error, - statusMessage: plugin ? undefined : localize('copilotAgent.pluginParseError', "Error parsing plugin."), - ...(plugin ? { agents: toCustomizationAgentRefs(plugin.agents) } : {}), - }, + customization: plugin + ? { + ...bundleResult.ref, + load: { kind: CustomizationLoadStatus.Loaded }, + children: toChildCustomizations([plugin]), + } + : { + ...bundleResult.ref, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, + }, pluginDir, plugin, }; @@ -1928,7 +1986,7 @@ class PluginController extends Disposable { private _hostSync: Promise = Promise.resolve([]); private _clientRevision = 0; private _hostRevision = 0; - private _lastAppliedRefs: readonly CustomizationRef[] = []; + private _lastAppliedRefs: readonly Customization[] = []; /** * Per-working-directory bundles built from on-disk discovery @@ -1961,12 +2019,12 @@ class PluginController extends Disposable { super.dispose(); } - public getConfiguredHostCustomizations(): readonly CustomizationRef[] { - return this._hostCustomizations.map(item => item.customization.customization); + public getConfiguredHostCustomizations(): readonly Customization[] { + return this._hostCustomizations.map(item => item.customization); } - public getSessionCustomizations(directory: URI | undefined): readonly SessionCustomization[] { - const result: SessionCustomization[] = [ + public getSessionCustomizations(directory: URI | undefined): readonly Customization[] { + const result: Customization[] = [ ...this._hostCustomizations.map(item => this._applyEnablement(item.customization)), ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), ]; @@ -1990,7 +2048,7 @@ class PluginController extends Disposable { * {@link SessionDiscoveredEntry} kicks off its `_refresh()` in its * constructor without anyone awaiting it. */ - public async getSessionCustomizationsSettled(directory: URI | undefined): Promise { + public async getSessionCustomizationsSettled(directory: URI | undefined): Promise { const entry = directory ? this._getOrCreateSessionEntry(directory) : undefined; await Promise.all([ this._hostSync.catch(err => { @@ -2066,7 +2124,8 @@ class PluginController extends Disposable { * changed since the last application. */ private _applyHostCustomizations(): void { - const customizations = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? []; + const entries = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? []; + const customizations = entries.map(toContainerCustomization); if (equals(customizations, this._lastAppliedRefs)) { return; } @@ -2075,9 +2134,8 @@ class PluginController extends Disposable { const revision = ++this._hostRevision; this._hostCustomizations = customizations.map(customization => ({ customization: { - customization, - enabled: true, - status: CustomizationStatus.Loading, + ...customization, + load: { kind: CustomizationLoadStatus.Loading }, }, })); this._onDidChange.fire(); @@ -2093,38 +2151,33 @@ class PluginController extends Disposable { }); } - public sync(clientId: string, customizations: CustomizationRef[], directory: URI | undefined, publish?: (action: SessionAction) => void) { + public sync(clientId: string, customizations: ClientPluginCustomization[], directory: URI | undefined, publish?: (action: SessionAction) => void) { const revision = ++this._clientRevision; this._clientCustomizations = customizations.map(customization => ({ customization: { - customization, + ...customization, clientId, - enabled: true, - status: CustomizationStatus.Loading, + load: { kind: CustomizationLoadStatus.Loading }, }, })); publish?.({ type: ActionType.SessionCustomizationsChanged, customizations: [...this.getSessionCustomizations(directory)], }); - const published = new Map(); + const published = new Map(); for (const customization of this._clientCustomizations) { const enabled = this._applyEnablement(customization.customization); - published.set(enabled.customization.uri, this._applyEnablement(customization.customization)); + published.set(enabled.uri, enabled); } const publishUpdate = (item: IResolvedCustomization) => { const customization = this._applyEnablement(item.customization); - if (equals(published.get(customization.customization.uri), customization)) { + if (equals(published.get(customization.uri), customization)) { return; } - published.set(customization.customization.uri, { ...customization }); + published.set(customization.uri, customization); publish?.({ type: ActionType.SessionCustomizationUpdated, - customization: customization.customization, - enabled: customization.enabled, - status: customization.status, - statusMessage: customization.statusMessage, - agents: customization.agents, + customization, }); }; @@ -2156,35 +2209,32 @@ class PluginController extends Disposable { }))); } - private _isEnabled(customization: SessionCustomization): boolean { - return this._enablement.get(customization.customization.uri) ?? customization.enabled; + private _isEnabled(customization: Customization): boolean { + return this._enablement.get(customization.uri) ?? customization.enabled; } - private _applyEnablement(customization: SessionCustomization): SessionCustomization { + private _applyEnablement(customization: Customization): Customization { const enabled = this._isEnabled(customization); return customization.enabled === enabled ? customization : { ...customization, enabled }; } - private async _resolveConfiguredCustomization(customization: CustomizationRef): Promise { + private async _resolveConfiguredCustomization(customization: Customization): Promise { const pluginDir = URI.parse(customization.uri); const parsed = await this._tryParsePlugin(pluginDir); if (!parsed) { return { customization: { - customization, - enabled: true, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), + ...customization, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, }, }; } return { customization: { - customization, - enabled: true, - status: CustomizationStatus.Loaded, - agents: toCustomizationAgentRefs(parsed.agents), + ...customization, + load: { kind: CustomizationLoadStatus.Loaded }, + children: toChildCustomizations([parsed]), }, pluginDir, plugin: parsed, @@ -2192,33 +2242,25 @@ class PluginController extends Disposable { } private async _resolveSyncedCustomization(item: ISyncedCustomization, clientId: string): Promise { + const baseCustomization: Customization = { ...item.customization, clientId }; if (!item.pluginDir) { - return { - customization: { - ...item.customization, - clientId, - }, - }; + return { customization: baseCustomization }; } const parsed = await this._tryParsePlugin(item.pluginDir); if (!parsed) { return { customization: { - ...item.customization, - clientId, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), + ...baseCustomization, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, }, }; } return { customization: { - ...item.customization, - customization: item.customization.customization, - clientId, - agents: toCustomizationAgentRefs(parsed.agents), + ...baseCustomization, + children: toChildCustomizations([parsed]), }, pluginDir: item.pluginDir, plugin: parsed, diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index 2df42810b5a2b..af849e86c9ffb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -9,8 +9,8 @@ import { OperatingSystem, OS } from '../../../../base/common/platform.js'; import { parseFrontMatter } from '../../../../base/common/yaml.js'; import { IFileService } from '../../../files/common/files.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import type { CustomizationAgentRef } from '../../common/state/protocol/state.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedAgent, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { type AgentCustomization, type ChildCustomization } from '../../common/state/protocol/state.js'; import { dirname } from '../../../../base/common/path.js'; type SessionHooks = NonNullable; @@ -106,15 +106,35 @@ export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], } /** - * Projects parsed plugin agents into the protocol's {@link CustomizationAgentRef} - * shape so they can be advertised on the owning {@link CustomizationRef.agents}. + * Projects parsed plugin agents into their protocol-level + * {@link AgentCustomization} shape. */ -export function toCustomizationAgentRefs(agents: readonly INamedPluginResource[]): CustomizationAgentRef[] { - return agents.map(a => ({ - uri: a.uri.toString(), - name: a.name, - ...(a.description ? { description: a.description } : {}), - })); +export function toAgentCustomizations(agents: readonly IParsedAgent[]): AgentCustomization[] { + return agents.map(a => a.customization); +} + +/** + * Collects every child customization (agent, skill, rule, hook, MCP + * server) produced by a parsed plugin, deduped by id. This is the single + * source of truth for populating a container customization's `children` + * array — every projector that produced an SDK config above derives its + * matching protocol child from the same parsed primitive. + */ +export function toChildCustomizations(plugins: readonly IParsedPlugin[]): ChildCustomization[] { + const byId = new Map(); + const add = (c: ChildCustomization) => { + if (!byId.has(c.id)) { + byId.set(c.id, c); + } + }; + for (const plugin of plugins) { + for (const a of plugin.agents) { add(a.customization); } + for (const s of plugin.skills) { add(s.customization); } + for (const r of plugin.instructions) { add(r.customization); } + for (const h of plugin.hooks) { add(h.customization); } + for (const m of plugin.mcpServers) { add(m.customization); } + } + return [...byId.values()]; } // --------------------------------------------------------------------------- diff --git a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts index c32a1497cd28f..0c4d1ad8e919f 100644 --- a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts +++ b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts @@ -10,8 +10,8 @@ import { basename, dirname } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; -import type { CustomizationRef } from '../../common/state/sessionState.js'; -import type { URI as ProtocolURI } from '../../common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../common/state/protocol/state.js'; import { DiscoveredType, type IDiscoveredFile } from '../copilot/sessionCustomizationDiscovery.js'; const DISPLAY_NAME = 'VS Code Synced Data'; @@ -35,7 +35,7 @@ function pluginDirForType(type: DiscoveredType): string { } interface IBundleResult { - readonly ref: CustomizationRef; + readonly ref: ClientPluginCustomization; } /** @@ -80,8 +80,8 @@ export class SessionPluginBundler extends Disposable { * Bundles the given files into the on-disk plugin directory. * * Overwrites any previous bundle for this working directory. Returns a - * {@link CustomizationRef} pointing at the on-disk plugin root with a - * content-based nonce, or `undefined` when there are no files. + * {@link ClientPluginCustomization} pointing at the on-disk plugin root + * with a content-based nonce, or `undefined` when there are no files. */ async bundle(files: readonly IDiscoveredFile[]): Promise { if (files.length === 0) { @@ -125,11 +125,14 @@ export class SessionPluginBundler extends Disposable { const nonce = String(hash(hashParts.join('\n'))); this._lastNonce = nonce; + const rootUriString = this._rootUri.toString() as ProtocolURI; return { ref: { - uri: this._rootUri.toString() as ProtocolURI, - displayName: DISPLAY_NAME, - description: `${files.length} customization(s) discovered for this session`, + type: CustomizationType.Plugin, + id: customizationId(rootUriString), + uri: rootUriString, + name: DISPLAY_NAME, + enabled: true, nonce, }, }; diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 81e984968aaf9..be186d1798319 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -23,7 +23,7 @@ import { ActionType, type SessionActiveClientChangedAction, type SessionTitleCha import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { ROOT_STATE_URI, StateComponents } from '../../common/state/sessionState.js'; +import { CustomizationType, ROOT_STATE_URI, StateComponents, customizationId } from '../../common/state/sessionState.js'; import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; @@ -642,8 +642,8 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, - { uri: 'file:///other/bar', displayName: 'Bar' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, + { type: CustomizationType.Plugin, id: customizationId('file:///other/bar'), uri: 'file:///other/bar', name: 'Bar', enabled: true }, ] }, }); @@ -668,8 +668,8 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, - { uri: 'file:///plugins/bar', displayName: 'Bar' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/bar'), uri: 'file:///plugins/bar', name: 'Bar', enabled: true }, ] }, }); @@ -691,7 +691,7 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, ] }, }; @@ -725,7 +725,7 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, ], }, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts index aebdafbc3fbea..5126b6af14d86 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts @@ -14,8 +14,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { CompletionItemKind } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; -import { CustomizationStatus, type CustomizationRef } from '../../common/state/sessionState.js'; +import { CustomizationType, MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { CustomizationLoadStatus, customizationId, type ClientPluginCustomization } from '../../common/state/sessionState.js'; import { AgentHostCompletions, CompletionTriggerCharacter } from '../../node/agentHostCompletions.js'; import { AgentHostSkillCompletionProvider } from '../../node/agentHostSkillCompletionProvider.js'; import { MockAgent } from './mockAgent.js'; @@ -38,10 +38,14 @@ suite('AgentHostSkillCompletionProvider', () => { return URI.from({ scheme: Schemas.inMemory, path }); } - function customization(root: URI, nonce?: string): CustomizationRef { + function customization(root: URI, nonce?: string): ClientPluginCustomization { + const uri = root.toString(); return { - uri: root.toString(), - displayName: root.path, + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: root.path, + enabled: true, ...(nonce !== undefined ? { nonce } : {}), }; } @@ -116,7 +120,7 @@ suite('AgentHostSkillCompletionProvider', () => { const globalCustomization = customization(globalRoot, 'global'); const agent = new MockAgent('mock'); agent.customizations = [globalCustomization]; - agent.getSessionCustomizations = async () => [{ customization: sessionCustomization, enabled: true, status: CustomizationStatus.Loaded }]; + agent.getSessionCustomizations = async () => [{ ...sessionCustomization, load: { kind: CustomizationLoadStatus.Loaded } }]; const provider = createProvider(agent); const result = await run(provider, '/'); @@ -130,7 +134,7 @@ suite('AgentHostSkillCompletionProvider', () => { const ref = customization(root, '1'); const agent = new MockAgent('mock'); agent.customizations = [ref]; - agent.getSessionCustomizations = async () => [{ customization: ref, enabled: false, status: CustomizationStatus.Loaded }]; + agent.getSessionCustomizations = async () => [{ ...ref, enabled: false, load: { kind: CustomizationLoadStatus.Loaded } }]; const provider = createProvider(agent); const result = await run(provider, '/'); diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts index 19514a21b334b..5a94df8b59555 100644 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -13,7 +13,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js'; +import { customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { AgentPluginManager } from '../../node/agentPluginManager.js'; suite('AgentPluginManager', () => { @@ -37,8 +38,16 @@ suite('AgentPluginManager', () => { return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString(); } - function makeRef(name: string, nonce?: string): CustomizationRef { - return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce }; + function makeRef(name: string, nonce?: string): ClientPluginCustomization { + const uri = pluginUri(name); + return { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: `Plugin ${name}`, + enabled: true, + ...(nonce !== undefined ? { nonce } : {}), + }; } async function seedPluginDir(name: string, files: Record): Promise { @@ -62,9 +71,9 @@ suite('AgentPluginManager', () => { makeRef('alpha', 'n1'), makeRef('beta', 'n2'), ]); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(results[0].customization.load?.kind, 'loaded'); assert.ok(results[0].pluginDir, 'should have pluginDir'); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(results[1].customization.load?.kind, 'loaded'); assert.ok(results[1].pluginDir, 'should have pluginDir'); }); @@ -72,8 +81,8 @@ suite('AgentPluginManager', () => { const results = await manager.syncCustomizations('test-client', [makeRef('nonexistent')]); assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Error); - assert.ok(results[0].customization.statusMessage); + assert.strictEqual(results[0].customization.load?.kind, 'error'); + assert.ok(results[0].customization.load?.kind === 'error' && results[0].customization.load.message); assert.strictEqual(results[0].pluginDir, undefined); }); @@ -84,19 +93,19 @@ suite('AgentPluginManager', () => { makeRef('good', 'n1'), makeRef('missing'), ]); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Error); + assert.strictEqual(results[1].customization.load?.kind, 'error'); assert.strictEqual(results[1].pluginDir, undefined); }); test('fires progress callback with changed customization status', async () => { await seedPluginDir('prog', { 'index.js': 'content' }); - const progressCalls: SessionCustomization[] = []; + const progressCalls: Customization[] = []; await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], status => { progressCalls.push(status); }); - assert.deepStrictEqual(progressCalls.map(call => call.status), [CustomizationStatus.Loaded]); + assert.deepStrictEqual(progressCalls.map(call => call.load?.kind), ['loaded']); }); test('skips copy when nonce matches', async () => { @@ -123,8 +132,8 @@ suite('AgentPluginManager', () => { ]); // Both should succeed without error - assert.strictEqual(r1[0].customization.status, CustomizationStatus.Loaded); - assert.strictEqual(r2[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(r1[0].customization.load?.kind, 'loaded'); + assert.strictEqual(r2[0].customization.load?.kind, 'loaded'); }); }); @@ -165,7 +174,7 @@ suite('AgentPluginManager', () => { const result = await manager2.syncCustomizations('test-client', [ref]); // Should be loaded from cache (nonce match), not error - assert.strictEqual(result[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(result[0].customization.load?.kind, 'loaded'); assert.ok(result[0].pluginDir); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5e64e62b9d90d..2bce4d638fcc5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -23,7 +23,7 @@ import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, MessageAttachmentKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, customizationId, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -1148,7 +1148,7 @@ suite('AgentService (node dispatcher)', () => { const activeClient: SessionActiveClient = { clientId: 'client-eager', tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], + customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'A', enabled: true }], }; const session = await service.createSession({ provider: 'copilot', activeClient }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 3b185251f29b8..60826d45b052b 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -21,9 +21,9 @@ import { AgentSession, IAgent } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { CustomizationStatus } from '../../common/state/protocol/state.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -212,7 +212,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [{ name: 'testTool', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///customizations/SKILL.md', displayName: 'Test Skill' }] + customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///customizations/SKILL.md'), uri: 'file:///customizations/SKILL.md', name: 'Test Skill', enabled: true }] }, }; stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test', clientSeq: 1 }); @@ -876,18 +876,11 @@ suite('AgentSideEffects', () => { test('calls setClientCustomizations and dispatches customizationsChanged once', async () => { setupSession(); - agent.getSessionCustomizations = async () => [ - { - customization: { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - { - customization: { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - ]; + const pluginA: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginA.id, uri: pluginA.uri, name: pluginA.name, enabled: true }; + const pluginBClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginB.id, uri: pluginB.uri, name: pluginB.name, enabled: true }; + agent.getSessionCustomizations = async () => [pluginA, pluginB]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -897,10 +890,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [], - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ] + customizations: [pluginAClient, pluginBClient] }, }; sideEffects.handleAction(sessionUri.toString(), action); @@ -910,10 +900,7 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ clientId: 'test-client', - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ], + customizations: [pluginAClient, pluginBClient], }]); const customizationActions = envelopes @@ -928,16 +915,13 @@ suite('AgentSideEffects', () => { test('dispatches customizationUpdated for sync progress after initial replacement', async () => { setupSession(); - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - let currentCustomizations: readonly SessionCustomization[] = []; + const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }; + let currentCustomizations: readonly Customization[] = []; agent.getSessionCustomizations = async () => currentCustomizations; agent.setClientCustomizations = async (session, clientId, customizations) => { agent.setClientCustomizationsCalls.push({ clientId, customizations }); - currentCustomizations = [{ - customization, - enabled: true, - status: CustomizationStatus.Loading, - }]; + const loading: Customization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loading } }; + currentCustomizations = [loading]; agent.fireProgress({ kind: 'action', session, @@ -947,19 +931,14 @@ suite('AgentSideEffects', () => { }, }); await new Promise(resolve => setTimeout(resolve, 0)); - currentCustomizations = [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + const loaded: Customization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }; + currentCustomizations = [loaded]; agent.fireProgress({ kind: 'action', session, action: { type: ActionType.SessionCustomizationUpdated, - customization, - enabled: true, - status: CustomizationStatus.Loaded, + customization: loaded, }, }); return currentCustomizations.map(customization => ({ customization })); @@ -973,7 +952,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [], - customizations: [customization], + customizations: [pluginAClient], }, }); await new Promise(resolve => setTimeout(resolve, 50)); @@ -983,17 +962,14 @@ suite('AgentSideEffects', () => { const firstCustomizationsChanged = customizationsChanged[0].action; assert.strictEqual(firstCustomizationsChanged.type, ActionType.SessionCustomizationsChanged); assert.deepStrictEqual(firstCustomizationsChanged.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loading, + ...pluginAClient, + load: { kind: CustomizationLoadStatus.Loading }, }]); const customizationUpdated = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated); assert.deepStrictEqual(customizationUpdated.map(e => e.action), [{ type: ActionType.SessionCustomizationUpdated, - customization, - enabled: true, - status: CustomizationStatus.Loaded, + customization: { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }, }]); }); @@ -1047,13 +1023,9 @@ suite('AgentSideEffects', () => { test('republishes agent and session customizations for existing sessions', async () => { setupSession('file:///workspace'); - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; + const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + agent.getSessionCustomizations = async () => [customization]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1073,11 +1045,7 @@ suite('AgentSideEffects', () => { const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1); assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); + assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]); }); test('updates telemetry level from root config', () => { @@ -1106,13 +1074,9 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); setupSession('file:///workspace'); - const customization = { uri: 'file:///plugin-b', displayName: 'Plugin B' }; + const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + agent.getSessionCustomizations = async () => [customization]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1126,18 +1090,14 @@ suite('AgentSideEffects', () => { const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged); assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); + assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]); }); test('does not republish when registerProgressListener is disposed', async () => { const listener = sideEffects.registerProgressListener(agent); setupSession('file:///workspace'); - agent.customizations = [{ uri: 'file:///plugin-c', displayName: 'Plugin C' }]; + agent.customizations = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-c'), uri: 'file:///plugin-c', name: 'Plugin C', enabled: true }]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1163,13 +1123,13 @@ suite('AgentSideEffects', () => { const action: SessionAction = { type: ActionType.SessionCustomizationToggled, - uri: 'file:///plugin-a', + id: 'file:///plugin-a', enabled: false, }; sideEffects.handleAction(sessionUri.toString(), action); assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [ - { uri: 'file:///plugin-a', enabled: false }, + { id: 'file:///plugin-a', enabled: false }, ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 7ee34eddeec61..6b9844c8138fc 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -45,13 +45,14 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ILogService, NullLogService } from '../../../log/common/log.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; +import { IAgentPluginManager } from '../../common/agentPluginManager.js'; import { ClaudeProxyService, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; @@ -580,6 +581,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); @@ -704,6 +710,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); @@ -757,6 +768,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 29d45161a8816..c18d3b994fb67 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -38,13 +38,14 @@ import { FileService } from '../../../files/common/fileService.js'; import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, SessionInputAnswerState, SessionInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type SessionInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { ClaudeAgentSession } from '../../node/claude/claudeAgentSession.js'; import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; @@ -62,6 +63,31 @@ interface IStartCall { readonly token: string; } +class FakeAgentPluginManager implements IAgentPluginManager { + declare readonly _serviceBrand: undefined; + readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); + + syncResult: readonly ISyncedCustomization[] | undefined; + syncCalls: { clientId: string; customizations: readonly ClientPluginCustomization[] }[] = []; + + async syncCustomizations( + clientId: string, + customizations: ClientPluginCustomization[], + progress?: (status: Customization) => void, + ): Promise { + this.syncCalls.push({ clientId, customizations: [...customizations] }); + if (this.syncResult) { + if (progress) { + for (const synced of this.syncResult) { + progress(synced.customization); + } + } + return [...this.syncResult]; + } + return []; + } +} + class FakeClaudeProxyService implements IClaudeProxyService { declare readonly _serviceBrand: undefined; @@ -422,12 +448,29 @@ class FakeQuery implements AsyncGenerator { setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise { this.recordedFlagSettings.push(s); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } - supportedCommands(): never { throw new Error('FakeQuery: supportedCommands not modeled'); } + + supportedCommands(): never { + return Promise.resolve([]) as never; + } supportedModels(): never { throw new Error('FakeQuery: supportedModels not modeled'); } supportedAgents(): never { throw new Error('FakeQuery: supportedAgents not modeled'); } mcpServerStatus(): never { throw new Error('FakeQuery: mcpServerStatus not modeled'); } getContextUsage(): never { throw new Error('FakeQuery: getContextUsage not modeled'); } - reloadPlugins(): never { throw new Error('FakeQuery: reloadPlugins not modeled'); } + /** Phase 11 — programmable tool-name snapshot returned by `reloadPlugins()`. */ + reloadPluginsResults: readonly string[][] = []; + reloadPluginsCallCount = 0; + reloadPlugins(): never { + this.reloadPluginsCallCount++; + const idx = Math.min(this.reloadPluginsCallCount - 1, this.reloadPluginsResults.length - 1); + const names = this.reloadPluginsResults[idx] ?? []; + return Promise.resolve({ + commands: names.map(name => ({ name, description: '', argumentHint: '' })), + agents: [], + plugins: [], + mcpServers: [], + error_count: 0, + }) as never; + } accountInfo(): never { throw new Error('FakeQuery: accountInfo not modeled'); } rewindFiles(): never { throw new Error('FakeQuery: rewindFiles not modeled'); } readFile(): never { throw new Error('FakeQuery: readFile not modeled'); } @@ -591,6 +634,7 @@ function createTestContext( [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -870,6 +914,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); @@ -931,6 +976,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -997,6 +1043,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -1063,6 +1110,7 @@ suite('ClaudeAgent', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -1089,6 +1137,7 @@ suite('ClaudeAgent', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -1928,6 +1977,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -2526,6 +2576,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2595,6 +2646,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2677,6 +2729,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2722,6 +2775,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3026,6 +3080,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new RecordingProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -3075,6 +3130,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -3428,6 +3484,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { [ILogService, new NullLogService()], [IAgentConfigurationService, fakeConfigService], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [ISessionDataService, sessionData], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); @@ -3438,6 +3495,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -4655,5 +4713,312 @@ suite('ClaudeAgent (Phase 13 — getSessionMessages)', () => { // #endregion +// #region Phase 11 — customizations / plugins + +suite('ClaudeAgent — Phase 11 customizations', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeSyncedRef(uri: string, dir: string): ISyncedCustomization { + return { + customization: { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: uri, + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + }, + pluginDir: URI.file(dir), + }; + } + + function makeClientCustomization(uri: string, name: string): ClientPluginCustomization { + return { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name, + enabled: true, + }; + } + + function buildCtxWith(pluginManager: FakeAgentPluginManager): ITestContext { + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + const sdk = new FakeClaudeAgentSdkService(); + const sessionData = new RecordingSessionDataService(createSessionDataService()); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + + const services = new ServiceCollection( + [ILogService, logService], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, pluginManager], + [IAgentHostGitService, createNoopGitService()], + [IAgentConfigurationService, configService], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + } + + test('setClientCustomizations forwards each item as a SessionCustomizationUpdated action', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a'), makeSyncedRef('https://b', '/p/b')]; + const { agent } = buildCtxWith(pm); + + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + const updates: { uri: string }[] = []; + disposables.add(agent.onDidSessionProgress(s => { + if (s.kind === 'action' && s.action.type === ActionType.SessionCustomizationUpdated) { + updates.push({ uri: s.action.customization.uri.toString() }); + } + })); + + const synced = await agent.setClientCustomizations(created.session, 'client-1', [ + makeClientCustomization('https://a', 'A'), + makeClientCustomization('https://b', 'B'), + ]); + + assert.strictEqual(synced.length, 2); + assert.ok(updates.some(u => u === undefined ? false : u.uri.includes('a')), `expected an update for plugin a; got ${JSON.stringify(updates)}`); + assert.ok(updates.some(u => u === undefined ? false : u.uri.includes('b')), `expected an update for plugin b; got ${JSON.stringify(updates)}`); + }); + + test('setCustomizationEnabled fans out to every in-memory session', async () => { + const pm = new FakeAgentPluginManager(); + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'a'), workingDirectory: URI.file('/work') }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'b'), workingDirectory: URI.file('/work') }); + + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared')]; + await agent.setClientCustomizations(s1.session, 'c', [makeClientCustomization('https://shared', 'S')]); + await agent.setClientCustomizations(s2.session, 'c', [makeClientCustomization('https://shared', 'S')]); + + // One fire per per-session diff change confirms fan-out. + let changes = 0; + disposables.add(agent.onDidCustomizationsChange(() => changes++)); + agent.setCustomizationEnabled(customizationId('https://shared'), false); + + assert.strictEqual(changes, 2); + }); + + test('getCustomizations returns [] — provider-level catalogue, not a cross-session aggregator', async () => { + const pm = new FakeAgentPluginManager(); + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'one'), workingDirectory: URI.file('/work') }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'two'), workingDirectory: URI.file('/work') }); + + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://a', '/p/a')]; + await agent.setClientCustomizations(s1.session, 'c', []); + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://b', '/p/b')]; + await agent.setClientCustomizations(s2.session, 'c', []); + + // `IAgent.getCustomizations()` is the provider-level catalogue + // (host-configured), NOT an aggregator across sessions. Claude has + // no host-configured customizations today, so [] is the contract. + // Client-pushed refs flow through `getSessionCustomizations` instead. + assert.deepStrictEqual(agent.getCustomizations(), []); + }); + + test('getSessionCustomizations resolves against a provisional session', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + assert.strictEqual(created.provisional, true); + + await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1); + }); + + test('send pre-flight: dirty customizations triggers a rebind (SDK plugin URI set is captured at startup, so any change must restart the Query)', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Stage 2 turns and park the iterator after turn 1's `result` so + // `_query` stays bound (mirroring the "reuse query" pattern). + const advance = new DeferredPromise(); + sdk.queryAdvance = async (idx: number) => { if (idx === 2) { await advance.p; } }; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.startupCallCount, 1); + + // Customization sync flips dirty; the next sendMessage's + // pre-flight rebinds so `Options.plugins` on the new Query + // includes the new path. + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + const firstQuery = sdk.warmQueries[0].produced!; + + const p2 = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await tick(); + advance.complete(); + await p2; + + assert.deepStrictEqual({ + reloadsOnFirstQuery: firstQuery.reloadPluginsCallCount, + startups: sdk.startupCallCount, + warmQueries: sdk.warmQueries.length, + }, { reloadsOnFirstQuery: 0, startups: 2, warmQueries: 2 }); + }); + + test('mid-turn setCustomizationEnabled does not affect the in-flight send (race coverage)', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Materialize, then drain the dirty bit from a customization + // sync so the pre-flight for the SECOND turn is clean. + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + pm.syncResult = [makeSyncedRef('https://x', '/p/x')]; + await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://x', 'X')]); + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + const session = agent.getSessionForTesting(created.session)!; + // First-turn materialize consumed the dirty bit from the sync + // above (plugin path baked into `Options.plugins` of the + // startup `Query`), so the pre-flight for the second turn + // starts clean. + assert.strictEqual(session.clientCustomizationsDiff.hasDifference, false); + + // Block the SECOND turn mid-iterator so a toggle can land while + // the SDK is mid-yield. + const gate = new DeferredPromise(); + sdk.queryAdvance = async (i: number) => { if (i === 2) { await gate.p; } }; + + const inflight = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await new Promise(r => setImmediate(r)); + + // Toggle a SYNCED customization during the in-flight turn. The + // diff flips dirty (state changed) but no SDK action drains + // during the current send — its pre-flight already passed. + const startupsBefore = sdk.startupCallCount; + agent.setCustomizationEnabled(customizationId('https://x'), false); + assert.strictEqual(session.clientCustomizationsDiff.hasDifference, true); + assert.strictEqual(sdk.startupCallCount, startupsBefore, 'no rebind during the in-flight turn'); + + gate.complete(); + await inflight; + }); + + test('getSessionCustomizations swallows SDK snapshot failure and returns the client-pushed projection', async () => { + // `snapshotResolvedCustomizations` calls `supportedAgents()` and + // `mcpServerStatus()` in `Promise.all`; the FakeQuery throws on + // both. The session should warn-log and still return the + // client-pushed slice rather than blanking the UI. + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent, sdk } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1, 'client-pushed projection survives SDK snapshot failure'); + assert.strictEqual(customizations[0].uri, 'https://a'); + }); + + test('changeAgent on a provisional session stashes the selection (no SDK contact) and lands on Options.agent at materialize', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/code-reviewer.md' }); + assert.strictEqual(sdk.startupCallCount, 0, 'no SDK startup from changeAgent on provisional'); + + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'code-reviewer', 'agent name resolved from file URI basename'); + }); + + test('changeAgent on a materialized session triggers a rebind with the new Options.agent on the rebuilt Query', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, undefined, 'no agent on first startup'); + + // Mid-session agent change: flips dirty, next send rebinds + // (SDK has no working runtime hook to swap the agent in place). + await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/planner.md' }); + await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + + assert.strictEqual(sdk.startupCallCount, 2, 'rebind on agent change'); + assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, 'planner', 'agent baked into rebuilt Options'); + }); + + test('changeAgent(undefined) clears the selection: rebind, Options.agent omitted', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ + workingDirectory: URI.file('/work'), + agent: { uri: 'file:///foo/agents/planner.md' }, + }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); + + await agent.changeAgent!(created.session, undefined); + await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + + assert.strictEqual(sdk.startupCallCount, 2); + assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, undefined, 'cleared agent omitted from rebuilt Options'); + }); +}); + +// #endregion + + diff --git a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts index 87d9a269de3fb..e76fe8a63f49c 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; +import { buildOptions, buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; +import type { IClaudeProxyHandle } from '../../node/claude/claudeProxyService.js'; suite('claudeSdkOptions / buildSubprocessEnv', () => { @@ -77,3 +79,51 @@ suite('claudeSdkOptions / buildSubprocessEnv', () => { assert.strictEqual(env.ELECTRON_RUN_AS_NODE, '1'); }); }); + +suite('claudeSdkOptions / buildOptions plugins projection', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const proxyHandle: IClaudeProxyHandle = { + baseUrl: 'http://127.0.0.1:0', + nonce: 'n', + dispose: () => { }, + }; + + function input(plugins: readonly URI[] | undefined) { + return { + sessionId: 's1', + workingDirectory: URI.file('/tmp/x'), + model: undefined, + abortController: new AbortController(), + permissionMode: 'default' as const, + canUseTool: async () => ({ behavior: 'allow' as const, updatedInput: {} }), + isResume: false, + mcpServers: undefined, + ...(plugins !== undefined ? { plugins } : {}), + }; + } + + test('non-empty plugins project to Options.plugins as local entries', async () => { + const opts = await buildOptions( + input([URI.file('/p/a'), URI.file('/p/b')]), + proxyHandle, + () => { }, + () => { }, + ); + assert.deepStrictEqual(opts.plugins, [ + { type: 'local', path: URI.file('/p/a').fsPath }, + { type: 'local', path: URI.file('/p/b').fsPath }, + ]); + }); + + test('empty plugins array omits Options.plugins', async () => { + const opts = await buildOptions(input([]), proxyHandle, () => { }, () => { }); + assert.strictEqual(opts.plugins, undefined); + }); + + test('undefined plugins omits Options.plugins', async () => { + const opts = await buildOptions(input(undefined), proxyHandle, () => { }, () => { }); + assert.strictEqual(opts.plugins, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index 4356b7dd69810..8d98c2bc3f4d1 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -141,6 +141,52 @@ suite('ClaudeSdkPipeline', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + suite('reloadPlugins', () => { + + test('forwards to the SDK Query', async () => { + let reloadCallCount = 0; + class WarmWithReload extends FakeWarmQuery { + override query(_prompt: string | AsyncIterable): Query { + this.queryCallCount++; + const q = new ImmediatelyDoneQuery(); + (q as unknown as { reloadPlugins: () => Promise<{ commands: { name: string }[] }> }).reloadPlugins = + async () => { reloadCallCount++; return { commands: [] }; }; + return q; + } + } + const controller = new AbortController(); + const warm = new WarmWithReload(); + const fileService = disposables.add(new FileService(new NullLogService())); + const fs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('file', fs)); + const db = new TestSessionDatabase(); + const dbRef: IReference = { object: db, dispose: () => { } }; + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [IFileService, fileService], + [IDiffComputeService, createZeroDiffComputeService()], + ); + const inst: IInstantiationService = disposables.add(new InstantiationService(services)); + const subagents = disposables.add(new SubagentRegistry()); + const pipeline = disposables.add(inst.createInstance( + ClaudeSdkPipeline, + 'sess-2', + URI.parse('claude:/sess-2'), + warm, + controller, + dbRef, + subagents, + undefined, + )); + // Bind the query by issuing a send (iterator closes immediately). + pipeline.send(makePrompt('p1'), 'turn-A').catch(() => { /* expected */ }); + await Promise.resolve(); + + await pipeline.reloadPlugins(); + assert.strictEqual(reloadCallCount, 1); + }); + }); + suite('initial state', () => { test('isResumed starts false and isAborted starts false', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f345884fbef9d..58930f2272a4e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -8,6 +8,8 @@ import assert from 'assert'; import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -29,7 +31,8 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, CustomizationStatus, ResponsePartKind, SessionCustomization, ToolCallConfirmationReason, ToolCallStatus, TurnState, type CustomizationRef, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type Customization, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -51,7 +54,7 @@ class TestAgentPluginManager implements IAgentPluginManager { readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); - async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { + async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[], _progress?: (status: Customization) => void): Promise { return []; } } @@ -608,9 +611,9 @@ suite('CopilotAgent', () => { suite('createSession activeClient eager-claim', () => { class SpyingPluginManager extends TestAgentPluginManager { - public readonly calls: { clientId: string; customizations: CustomizationRef[] }[] = []; + public readonly calls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; - override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { + override async syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], _progress?: (status: Customization) => void): Promise { this.calls.push({ clientId, customizations: [...customizations] }); return []; } @@ -629,7 +632,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const customizations: CustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; + const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }]; const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'test-session'), workingDirectory: URI.file('/workspace'), @@ -681,9 +684,9 @@ suite('CopilotAgent', () => { ); class PluginDirSpyManager extends TestAgentPluginManager { - override async syncCustomizations(_clientId: string, customizations: CustomizationRef[]): Promise { + override async syncCustomizations(_clientId: string, customizations: ClientPluginCustomization[]): Promise { return customizations.map(c => ({ - customization: { customization: c, enabled: true, status: CustomizationStatus.Loaded }, + customization: { ...c, load: { kind: CustomizationLoadStatus.Loaded } }, pluginDir, })); } @@ -705,18 +708,21 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); - await agent.setClientCustomizations(session, 'client-1', [{ uri: pluginDir.toString(), displayName: 'Plugin A' }]); + await agent.setClientCustomizations(session, 'client-1', [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]); // Wait for the deferred resolution chain in PluginController.sync. await new Promise(r => setTimeout(r, 50)); - const updatesWithAgents = actions + const updatesWithChildren = actions .filter(a => a.type === ActionType.SessionCustomizationUpdated) .filter((a): a is Extract => true) - .filter(a => a.agents !== undefined); + .filter(a => a.customization.children !== undefined); - assert.strictEqual(updatesWithAgents.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed agents'); - assert.deepStrictEqual(updatesWithAgents.at(-1)!.agents, [{ + assert.strictEqual(updatesWithChildren.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed children'); + const agentChildren = updatesWithChildren.at(-1)!.customization.children!.filter(c => c.type === CustomizationType.Agent); + assert.deepStrictEqual(agentChildren, [{ + type: CustomizationType.Agent, + id: customizationId(URI.joinPath(pluginDir, 'agents', 'helper.md').toString()), uri: URI.joinPath(pluginDir, 'agents', 'helper.md').toString(), name: 'helper-agent', description: 'helps out', @@ -942,6 +948,133 @@ suite('CopilotAgent', () => { }); }); + suite('_resumeSession dedup', () => { + // Regression: two concurrent paths (e.g. an outdated-config refresh in + // `sendMessage` and a `getSessionMessages` subscribe) each calling + // `_resumeSession(id)` used to construct two `CopilotAgentSession` + // entries for the same id; the second `_sessions.set(id, …)` on the + // underlying `DisposableMap` disposed the first one mid + // `initializeSession()`, producing 'Trying to add a disposable to a + // DisposableStore that has already been disposed' warnings and a + // half-initialised session with no event subscriptions. + + type AgentInternals = { + _resumeSession: (id: string) => Promise; + _doResumeSession: (id: string) => Promise; + }; + const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); + + test('dedupes concurrent calls for the same sessionId', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const deferred = new DeferredPromise(); + let doResumeCalls = 0; + internals._doResumeSession = () => { + doResumeCalls++; + return deferred.p; + }; + try { + const p1 = internals._resumeSession('s1'); + const p2 = internals._resumeSession('s1'); + assert.strictEqual(p1, p2); + assert.strictEqual(doResumeCalls, 1); + + const session = makeFakeSession(); + deferred.complete(session); + assert.strictEqual(await p1, session); + assert.strictEqual(await p2, session); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let doResumeCalls = 0; + internals._doResumeSession = async () => { + doResumeCalls++; + return makeFakeSession(); + }; + try { + await internals._resumeSession('s1'); + await internals._resumeSession('s1'); + assert.strictEqual(doResumeCalls, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry on rejection so the next call retries', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let attempt = 0; + internals._doResumeSession = async () => { + attempt++; + if (attempt === 1) { + throw new Error('first failed'); + } + return makeFakeSession(); + }; + try { + await assert.rejects(() => internals._resumeSession('s1'), /first failed/); + await internals._resumeSession('s1'); + assert.strictEqual(attempt, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('does not dedupe across different sessionIds', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const ids: string[] = []; + internals._doResumeSession = async (id: string) => { + ids.push(id); + return makeFakeSession(); + }; + try { + await Promise.all([ + internals._resumeSession('s1'), + internals._resumeSession('s2'), + ]); + assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); + } finally { + await disposeAgent(agent); + } + }); + + test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { + // Without this guard an in-flight `_resumeSession` / + // `_materializeProvisional` whose `initializeSession()` + // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` + // has run would call `_sessions.set(...)` on a disposed + // DisposableMap, leaking the session and reproducing the + // 'Trying to add a disposable to a DisposableStore that has + // already been disposed' warning this PR exists to eliminate. + const agent = createTestAgent(disposables); + const internals = agent as unknown as { + _registerInitializedSession: (id: string, s: CopilotAgentSession) => void; + _shutdownPromise: Promise | undefined; + }; + let disposed = 0; + const fakeSession = { dispose: () => { disposed++; } } as unknown as CopilotAgentSession; + internals._shutdownPromise = Promise.resolve(); + try { + assert.throws( + () => internals._registerInitializedSession('s1', fakeSession), + (err: unknown) => isCancellationError(err), + ); + assert.strictEqual(disposed, 1, 'session should be disposed by the guard'); + } finally { + // Clear the fake shutdown promise so disposeAgent doesn't + // short-circuit and leave real state behind. + internals._shutdownPromise = undefined; + await disposeAgent(agent); + } + }); + }); + suite('worktree announcement', () => { // Drives a real session through worktree creation (calling the // agent's _resolveSessionWorkingDirectory via a test seam so we don't diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts index 8f2eb15960d75..20f8105753f7e 100644 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -16,7 +16,18 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin, IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; + +function stubMcpCustomization(name = 'test'): McpServerCustomization { + return { type: CustomizationType.McpServer, id: `mcp:${name}`, uri: 'file:///plugin', name }; +} +function stubHookCustomization(type: string): HookCustomization { + return { type: CustomizationType.Hook, id: `hook:${type}`, uri: 'file:///plugin/hooks.json', name: 'hooks.json' }; +} +function stubSkillCustomization(name: string): SkillCustomization { + return { type: CustomizationType.Skill, id: `skill:${name}`, uri: `file:///${name}/SKILL.md`, name }; +} suite('copilotPluginConverters', () => { @@ -46,6 +57,7 @@ suite('copilotPluginConverters', () => { env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, cwd: '/workspace', }, + customization: stubMcpCustomization('test-server'), }]; const result = toSdkMcpServers(defs); @@ -70,6 +82,7 @@ suite('copilotPluginConverters', () => { url: 'https://example.com/mcp', headers: { 'Authorization': 'Bearer token' }, }, + customization: stubMcpCustomization('remote-server'), }]; const result = toSdkMcpServers(defs); @@ -96,6 +109,7 @@ suite('copilotPluginConverters', () => { type: McpServerType.LOCAL, command: 'echo', }, + customization: stubMcpCustomization('minimal'), }]; const result = toSdkMcpServers(defs); @@ -114,6 +128,7 @@ suite('copilotPluginConverters', () => { command: 'test', env: { KEEP: 'value', DROP: null as unknown as string }, }, + customization: stubMcpCustomization('with-null-env'), }]; const result = toSdkMcpServers(defs); @@ -279,6 +294,7 @@ suite('copilotPluginConverters', () => { commands: [{ command }], uri: URI.file('/plugin/hooks.json'), originalId: type, + customization: stubHookCustomization(type), }; } @@ -395,27 +411,29 @@ suite('copilotPluginConverters', () => { test('returns true for same content', () => { const a = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], mcpServers: [{ name: 'server', uri: URI.file('/mcp'), configuration: { type: McpServerType.LOCAL, command: 'node' }, + customization: stubMcpCustomization('server'), }], }); const b = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], mcpServers: [{ name: 'server', uri: URI.file('/mcp'), configuration: { type: McpServerType.LOCAL, command: 'node' }, + customization: stubMcpCustomization('server'), }], }); assert.strictEqual(parsedPluginsEqual([a], [b]), true); }); test('returns false for different content', () => { - const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] }); - const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] }); + const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill] }); + const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b', customization: stubSkillCustomization('b') } satisfies IParsedSkill] }); assert.strictEqual(parsedPluginsEqual([a], [b]), false); }); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts new file mode 100644 index 0000000000000..2c2153e5cf85a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../instantiation/test/common/instantiationServiceMock.js'; +import { FileService } from '../../../../files/common/fileService.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationLoadStatus, CustomizationType, type ClientPluginCustomization, type Customization } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; +import { ClaudeSdkCustomizationBundler } from '../../../node/claude/customizations/claudeSdkCustomizationBundler.js'; + +suite('ClaudeSdkCustomizationBundler', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let bundler: ClaudeSdkCustomizationBundler; + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + const workingDir = URI.file('/work'); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const inst = disposables.add(new TestInstantiationService()); + inst.stub(IFileService, fileService); + inst.stub(IAgentPluginManager, { + basePath, + syncCustomizations: async (_clientId: string, _refs: readonly ClientPluginCustomization[]): Promise => [], + } satisfies Partial as unknown as IAgentPluginManager); + bundler = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, workingDir)); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function snapshot(overrides: Partial = {}): ISdkResolvedCustomizations { + return { + commands: [], + agents: [], + mcpServers: [], + ...overrides, + }; + } + + test('returns undefined when SDK snapshot has no commands or agents', async () => { + const result = await bundler.bundle(snapshot()); + assert.strictEqual(result, undefined); + }); + + test('writes manifest, agent files, and skill subdirs for a snapshot with agents and commands', async () => { + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'planner', description: 'Plans things', model: 'claude' }], + commands: [{ name: 'doit', description: 'Does it', argumentHint: '' }], + })); + + assert.ok(result, 'should produce a bundle'); + const rootUri = URI.parse(result!.uri); + const manifest = await fileService.readFile(URI.joinPath(rootUri, '.plugin', 'plugin.json')); + const manifestJson = JSON.parse(manifest.value.toString()); + assert.strictEqual(manifestJson.name, 'claude-discovered'); + const agentFile = await fileService.readFile(URI.joinPath(rootUri, 'agents', 'planner.md')); + assert.match(agentFile.value.toString(), /name: "planner"/); + assert.match(agentFile.value.toString(), /description: "Plans things"/); + const skillFile = await fileService.readFile(URI.joinPath(rootUri, 'skills', 'doit', 'SKILL.md')); + assert.match(skillFile.value.toString(), /name: "doit"/); + assert.match(skillFile.value.toString(), /Usage: ``/); + }); + + test('children include agent customizations sourced from the SDK snapshot with on-disk file URIs', async () => { + const result = await bundler.bundle(snapshot({ + agents: [ + { name: 'a1', description: 'one', model: 'm' }, + { name: 'a2', description: 'two', model: 'm' }, + ], + })); + const agents = result!.children!.filter(c => c.type === CustomizationType.Agent); + assert.deepStrictEqual(agents.map(a => a.name), ['a1', 'a2']); + assert.ok(agents[0].uri.endsWith('/agents/a1.md'), `expected on-disk path, got ${agents[0].uri}`); + assert.ok(agents[1].uri.endsWith('/agents/a2.md')); + }); + + test('repeated bundle with same snapshot is nonce-stable and does not rewrite', async () => { + const r1 = await bundler.bundle(snapshot({ + agents: [{ name: 'p', description: 'd', model: 'm' }], + })); + const rootUri = URI.parse(r1!.uri); + const agentUri = URI.joinPath(rootUri, 'agents', 'p.md'); + const stat1 = await fileService.stat(agentUri); + + const r2 = await bundler.bundle(snapshot({ + agents: [{ name: 'p', description: 'd', model: 'm' }], + })); + assert.strictEqual(r1!.id, r2!.id); + const stat2 = await fileService.stat(agentUri); + assert.strictEqual(stat1.mtime, stat2.mtime, 'unchanged snapshot must not rewrite the on-disk tree'); + }); + + test('changed snapshot deletes prior bundle tree before writing the new one', async () => { + await bundler.bundle(snapshot({ + agents: [{ name: 'old', description: 'd', model: 'm' }], + })); + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'new', description: 'd', model: 'm' }], + })); + const rootUri = URI.parse(result!.uri); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'new.md'))); + assert.ok(!(await fileService.exists(URI.joinPath(rootUri, 'agents', 'old.md'))), 'previous agent file should be deleted'); + }); + + test('sanitises agent and command names — invalid chars replaced, length capped, empty falls back to "unnamed"', async () => { + const longName = 'a'.repeat(200); + const result = await bundler.bundle(snapshot({ + agents: [ + { name: 'has spaces & slashes/here', description: 'd', model: 'm' }, + { name: longName, description: 'd', model: 'm' }, + { name: '!!!', description: 'd', model: 'm' }, + ], + })); + const rootUri = URI.parse(result!.uri); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'has_spaces___slashes_here.md'))); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', `${'a'.repeat(128)}.md`))); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', '___.md'))); + }); + + test('discoverable bundles for different working directories namespace by hash so they do not collide', async () => { + const inst = disposables.add(new TestInstantiationService()); + inst.stub(IFileService, fileService); + inst.stub(IAgentPluginManager, { + basePath, + } satisfies Partial as unknown as IAgentPluginManager); + const other = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, URI.file('/other-work'))); + + const a = await bundler.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); + const b = await other.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); + assert.notStrictEqual(a!.uri, b!.uri); + }); + + test('returned Customization carries the expected shape (load Loaded, enabled true, name)', async () => { + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'a', description: 'd', model: 'm' }], + commands: [{ name: 'c', description: 'd', argumentHint: '' }], + })); + assert.deepStrictEqual({ + enabled: result!.enabled, + loadKind: result!.load?.kind, + type: result!.type, + }, { + enabled: true, + loadKind: CustomizationLoadStatus.Loaded, + type: CustomizationType.Plugin, + }); + assert.ok(typeof result!.name === 'string' && result!.name.length > 0); + }); + + // Smoke: ensure return type compiles against Customization + function _typeCheck(): Customization | undefined { + return undefined; + } + void _typeCheck; +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts new file mode 100644 index 0000000000000..1c7791b42d0a5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationLoadStatus, CustomizationType, customizationId } from '../../../common/state/sessionState.js'; +import { SessionClientCustomizationsDiff } from '../../../node/claude/customizations/claudeSessionClientCustomizationsModel.js'; + +function synced(uri: string, opts: { dir?: string; enabled?: boolean; nonce?: string; name?: string } = {}): ISyncedCustomization { + return { + customization: { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: opts.name ?? uri, + enabled: opts.enabled ?? true, + load: { kind: CustomizationLoadStatus.Loaded }, + ...(opts.nonce !== undefined ? { nonce: opts.nonce } : {}), + }, + ...(opts.dir !== undefined ? { pluginDir: URI.file(opts.dir) } : {}), + }; +} + +suite('SessionClientCustomizationsDiff', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('fresh diff: empty, not dirty, no enabled paths', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + assert.deepStrictEqual(diff.model.state.get().synced, []); + assert.strictEqual(diff.hasDifference, false); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get(), []); + }); + + test('setSyncedCustomizations flips dirty and fires onDidChange', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(diff.hasDifference, true); + assert.strictEqual(fires, 1); + }); + + test('enabledPluginPaths excludes entries without pluginDir', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([ + synced('https://a', { dir: '/p/a' }), + synced('https://b'), + ]); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get().map(u => u.fsPath), [URI.file('/p/a').fsPath]); + }); + + test('setEnabled(false) removes from enabled paths and flips dirty exactly when value changes', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + assert.strictEqual(diff.hasDifference, false); + + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + + const id = customizationId('https://a'); + diff.model.setEnabled(id, false); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get(), []); + assert.strictEqual(diff.hasDifference, true); + assert.strictEqual(fires, 1); + + diff.model.setEnabled(id, false); // no change → no fire, stays dirty + assert.strictEqual(fires, 1); + }); + + test('default enablement is true (absent entry counts as enabled)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(diff.model.enabledPluginPaths.get().length, 1); + }); + + test('setEnabled(true) is a no-op for default-enabled entries', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setEnabled(customizationId('https://a'), true); + assert.strictEqual(fires, 0); + assert.strictEqual(diff.hasDifference, false); + }); + + test('consume returns current paths and clears dirty', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + const paths = diff.consume(); + assert.strictEqual(paths.length, 1); + assert.strictEqual(diff.hasDifference, false); + }); + + test('markDirty re-flips after failed downstream reload', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + assert.strictEqual(diff.hasDifference, false); + diff.markDirty(); + assert.strictEqual(diff.hasDifference, true); + }); + + test('structurally-equivalent re-send is deduped (no fire, no dirty)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(fires, 0); + assert.strictEqual(diff.hasDifference, false); + }); + + test('toggling enablement of customization without pluginDir still flips dirty (no-restart optimisation intentionally given up: rebind is cheap and correctness > efficiency)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a')]); + diff.consume(); + diff.model.setEnabled(customizationId('https://a'), false); + assert.strictEqual(diff.hasDifference, true); + }); + + test('nonce change at same URI / pluginDir flips dirty', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v1' })]); + diff.consume(); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v2' })]); + assert.strictEqual(diff.hasDifference, true); + }); + + test('name change at same URI flips dirty (state observable fires for workbench refetch)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', name: 'A' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', name: 'A renamed' })]); + assert.strictEqual(fires, 1); + assert.strictEqual(diff.hasDifference, true); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts new file mode 100644 index 0000000000000..dc334735297ea --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationLoadStatus, CustomizationType, customizationId, type Customization } from '../../../common/state/sessionState.js'; +import { projectSessionCustomizations } from '../../../node/claude/customizations/claudeSessionCustomizationsProjector.js'; + +function client(uri: string, enabled = true): ISyncedCustomization { + return { + customization: { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: uri, + enabled, + load: { kind: CustomizationLoadStatus.Loaded }, + }, + }; +} + +function discoveredBundle(uri: string): Customization { + return { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: 'VS Code Synced Data', + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + }; +} + +suite('projectSessionCustomizations', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns only client-pushed entries when no discovery bundle', () => { + const result = projectSessionCustomizations([client('https://a')], new Map(), undefined); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].uri.toString(), 'https://a'); + assert.strictEqual(result[0].enabled, true); + }); + + test('overlays enablement map (keyed by id) on client-pushed entries', () => { + const result = projectSessionCustomizations( + [client('https://a'), client('https://b')], + new Map([[customizationId('https://a'), false]]), + undefined, + ); + assert.strictEqual(result.find(c => c.uri.toString() === 'https://a')?.enabled, false); + assert.strictEqual(result.find(c => c.uri.toString() === 'https://b')?.enabled, true); + }); + + test('appends the discovery bundle verbatim', () => { + const bundleUri = URI.file('/tmp/host-discovery/x').toString(); + const result = projectSessionCustomizations( + [client('https://a')], + new Map(), + discoveredBundle(bundleUri), + ); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[1].uri.toString(), bundleUri); + assert.strictEqual(result[1].enabled, true); + }); + + test('discovery bundle enablement is not overlaid from the map', () => { + const bundleUri = URI.file('/tmp/host-discovery/x').toString(); + const result = projectSessionCustomizations( + [], + new Map([[customizationId(bundleUri), false]]), + discoveredBundle(bundleUri), + ); + assert.strictEqual(result[0].enabled, true); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 0809a65b55b52..4ff0a010b7a70 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -14,7 +14,7 @@ import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryReco import { ProtectedResourceMetadata, type MessageAttachment, type ModelSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -55,13 +55,13 @@ export class MockAgent implements IAgent { readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: ModelSelection }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; - readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = []; - readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; + readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; + readonly setCustomizationEnabledCalls: { id: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ - customizations: CustomizationRef[] = []; + customizations: Customization[] = []; private readonly _onDidCustomizationsChange = new Emitter(); readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; - getSessionCustomizations?: (session: URI) => Promise; + getSessionCustomizations?: (session: URI) => Promise; /** * Configurable session history. Tests construct {@link IHistoryRecord} @@ -165,17 +165,16 @@ export class MockAgent implements IAgent { return true; } - getCustomizations(): CustomizationRef[] { + getCustomizations(): Customization[] { return this.customizations; } - async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { this.setClientCustomizationsCalls.push({ clientId, customizations }); const results: ISyncedCustomization[] = customizations.map(c => ({ customization: { - customization: c, - enabled: true, - status: CustomizationStatus.Loaded, + ...c, + load: { kind: CustomizationLoadStatus.Loaded }, }, })); this._onDidSessionProgress.fire({ @@ -189,8 +188,8 @@ export class MockAgent implements IAgent { return results; } - setCustomizationEnabled(uri: string, enabled: boolean): void { - this.setCustomizationEnabledCalls.push({ uri, enabled }); + setCustomizationEnabled(id: string, enabled: boolean): void { + this.setCustomizationEnabledCalls.push({ id, enabled }); } setClientTools(): void { } diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 40cd62cc117e7..a0d4a40c1cee9 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,7 +7,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { changesetReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationStatus, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type ChangesetState, type CustomizationAgentRef, type CustomizationRef, type SessionState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationLoadStatus, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type SessionState } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { return { @@ -249,87 +250,46 @@ suite('changesetReducer', () => { }); }); -suite('sessionReducer – SessionCustomizationUpdated.agents', () => { +suite('sessionReducer – SessionCustomizationUpdated', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const ref: CustomizationRef = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - const agentA: CustomizationAgentRef = { uri: 'file:///plugin-a/agents/helper.md', name: 'helper' }; - const agentB: CustomizationAgentRef = { uri: 'file:///plugin-a/agents/reviewer.md', name: 'reviewer', description: 'reviews code' }; + const agentA: AgentCustomization = { type: CustomizationType.Agent, id: 'file:///plugin-a/agents/helper.md', uri: 'file:///plugin-a/agents/helper.md', name: 'helper' }; + const agentB: AgentCustomization = { type: CustomizationType.Agent, id: 'file:///plugin-a/agents/reviewer.md', uri: 'file:///plugin-a/agents/reviewer.md', name: 'reviewer', description: 'reviews code' }; - function withCustomization(status: CustomizationStatus): SessionState { - return sessionReducer(makeSession(), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, + function pluginA(extra: Partial = {}): Customization { + return { + type: CustomizationType.Plugin, + id: 'file:///plugin-a', + uri: 'file:///plugin-a', + name: 'Plugin A', enabled: true, - status, - }); + ...extra, + }; } - test('insert: persists agents from the action onto SessionCustomization', () => { + test('insert: appends a new top-level customization with its children', () => { + const customization = pluginA({ load: { kind: CustomizationLoadStatus.Loaded }, children: [agentA, agentB] }); const state = sessionReducer(makeSession(), { type: ActionType.SessionCustomizationUpdated, - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA, agentB], - }); - - assert.deepStrictEqual(state.customizations, [{ - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA, agentB], - }]); - }); - - test('update: replaces previously-set agents when the action carries a new array', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], - }); - const next = sessionReducer(seeded, { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentB], + customization, }); - assert.deepStrictEqual(next.customizations?.[0].agents, [agentB]); - }); - - test('update: preserves existing agents when the action omits the field', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], - }); - const next = sessionReducer(seeded, { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - status: CustomizationStatus.Loaded, - }); - - assert.deepStrictEqual(next.customizations?.[0], { - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA], - }); + assert.deepStrictEqual(state.customizations, [customization]); }); - test('update: an empty agents array is respected (means "no agents contributed")', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { + test('update: replaces the matching entry entirely', () => { + const initial = pluginA({ load: { kind: CustomizationLoadStatus.Loading }, children: [agentA] }); + const seeded = sessionReducer(makeSession(), { type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], + customization: initial, }); + const updated = pluginA({ load: { kind: CustomizationLoadStatus.Loaded }, children: [agentB] }); const next = sessionReducer(seeded, { type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [], + customization: updated, }); - assert.deepStrictEqual(next.customizations?.[0].agents, []); + assert.deepStrictEqual(next.customizations, [updated]); }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index bf4be87e326df..ffce923f8b6d2 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -108,7 +108,7 @@ suite('SessionCustomizationDiscovery + SessionPluginBundler', () => { const result = await bundler.bundle(files); assert.ok(result); - assert.strictEqual(result.ref.displayName, 'VS Code Synced Data'); + assert.strictEqual(result.ref.name, 'VS Code Synced Data'); assert.ok(result.ref.nonce); const root = bundler.rootUri; diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index b084826fd0114..1d7c43ceff922 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -14,6 +14,8 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { parseFrontMatter } from '../../../base/common/yaml.js'; import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js'; +import { CustomizationType, type AgentCustomization, type HookCustomization, type McpServerCustomization, type RuleCustomization, type SkillCustomization } from '../../agentHost/common/state/protocol/state.js'; +import { customizationId } from '../../agentHost/common/state/sessionState.js'; // --------------------------------------------------------------------------- // Types @@ -49,12 +51,20 @@ export interface IParsedHookGroup { readonly uri: URI; /** Original key as it appears in the hook file. */ readonly originalId: string; + /** + * Protocol-level projection of this hook group as a child customization. + * Multiple groups parsed from the same file share the same `customization.id` + * so consumers can dedupe by id when collecting customizations. + */ + readonly customization: HookCustomization; } export interface IMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; readonly uri: URI; + /** Protocol-level projection of this MCP server as a child customization. */ + readonly customization: McpServerCustomization; } /** A named resource (skill, agent, command, or instruction) within a plugin. */ @@ -68,13 +78,28 @@ export interface INamedPluginResource { readonly description?: string; } +/** A parsed agent paired with its protocol-level child customization. */ +export interface IParsedAgent extends INamedPluginResource { + readonly customization: AgentCustomization; +} + +/** A parsed skill paired with its protocol-level child customization. */ +export interface IParsedSkill extends INamedPluginResource { + readonly customization: SkillCustomization; +} + +/** A parsed rule (instruction) paired with its protocol-level child customization. */ +export interface IParsedRule extends INamedPluginResource { + readonly customization: RuleCustomization; +} + /** The result of parsing a single plugin directory. */ export interface IParsedPlugin { readonly hooks: readonly IParsedHookGroup[]; readonly mcpServers: readonly IMcpServerDefinition[]; - readonly skills: readonly INamedPluginResource[]; - readonly agents: readonly INamedPluginResource[]; - readonly instructions: readonly INamedPluginResource[]; + readonly skills: readonly IParsedSkill[]; + readonly agents: readonly IParsedAgent[]; + readonly instructions: readonly IParsedRule[]; } // --------------------------------------------------------------------------- @@ -143,6 +168,79 @@ export async function detectPluginFormat(pluginUri: URI, fileService: IFileServi return COPILOT_FORMAT; } +// --------------------------------------------------------------------------- +// Child customization helpers +// --------------------------------------------------------------------------- + +/** + * Mints a child-customization id from a source uri plus an optional opaque + * disambiguator. Used when multiple customizations are declared inline in + * a single file (e.g. two MCP servers in one `.mcp.json`, or two hook + * lifecycle groups in one hook file). + * + * Percent-encodes any pre-existing `#` in the URI before appending the + * disambiguating fragment so the resulting id can never collide with a + * URI that happens to already contain a matching fragment. + */ +function buildChildId(uri: URI, disambiguator?: string): string { + const base = customizationId(uri.toString()); + if (!disambiguator) { + return base; + } + return `${base.replace(/#/g, '%23')}#${disambiguator}`; +} + +function makeAgentCustomization(resource: INamedPluginResource): AgentCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Agent, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeSkillCustomization(resource: INamedPluginResource): SkillCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Skill, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeRuleCustomization(resource: INamedPluginResource): RuleCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Rule, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeHookCustomization(hookUri: URI): HookCustomization { + return { + type: CustomizationType.Hook, + id: buildChildId(hookUri), + uri: hookUri.toString(), + name: basename(hookUri), + }; +} + +function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { + return { + type: CustomizationType.McpServer, + id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`), + uri: definitionUri.toString(), + name, + }; +} + // --------------------------------------------------------------------------- // Component path config // --------------------------------------------------------------------------- @@ -359,7 +457,7 @@ export function interpolateMcpPluginRoot( interpolated = remote; } - return { name: def.name, configuration: interpolated, uri: def.uri }; + return { name: def.name, configuration: interpolated, uri: def.uri, customization: def.customization }; } /** @@ -543,6 +641,7 @@ function parseHooksJson( const hooksObj = hooks as Record; const result: IParsedHookGroup[] = []; + const customization = makeHookCustomization(hookUri); for (const originalId of Object.keys(hooksObj)) { const canonicalType = HOOK_TYPE_MAP[originalId]; @@ -561,7 +660,7 @@ function parseHooksJson( } if (commands.length > 0) { - result.push({ type: canonicalType, commands, uri: hookUri, originalId }); + result.push({ type: canonicalType, commands, uri: hookUri, originalId, customization }); } } @@ -902,7 +1001,12 @@ export function parseMcpServerDefinitionMap( continue; } - let def: IMcpServerDefinition = { name, configuration, uri: definitionURI }; + let def: IMcpServerDefinition = { + name, + configuration, + uri: definitionURI, + customization: makeMcpServerCustomization(definitionURI, name), + }; if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) { def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar); } @@ -974,6 +1078,24 @@ export async function parsePlugin( readInstructionComponents(instructionDirs, fileService), ]); - return { hooks, mcpServers, skills, agents, instructions }; + return { + hooks, + mcpServers, + skills: skills.map(toParsedSkill), + agents: agents.map(toParsedAgent), + instructions: instructions.map(toParsedRule), + }; +} + +function toParsedAgent(resource: INamedPluginResource): IParsedAgent { + return { ...resource, customization: makeAgentCustomization(resource) }; +} + +function toParsedSkill(resource: INamedPluginResource): IParsedSkill { + return { ...resource, customization: makeSkillCustomization(resource) }; +} + +function toParsedRule(resource: INamedPluginResource): IParsedRule { + return { ...resource, customization: makeRuleCustomization(resource) }; } diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index 9d9a69923ed63..f238130cb8ef6 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -7,6 +7,11 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +import { CustomizationType, type McpServerCustomization } from '../../../agentHost/common/state/protocol/state.js'; + +function stubMcpCustomization(): McpServerCustomization { + return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///plugin', name: 'test' }; +} import { parseComponentPathConfig, resolveComponentDirs, @@ -242,6 +247,7 @@ suite('pluginParsers', () => { command: '${MY_TOOL}', args: ['--key=${API_KEY}'], }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${env:MY_TOOL}'); @@ -256,6 +262,7 @@ suite('pluginParsers', () => { type: McpServerType.LOCAL as const, command: '${env:ALREADY_QUALIFIED}', }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${env:ALREADY_QUALIFIED}'); @@ -269,6 +276,7 @@ suite('pluginParsers', () => { type: McpServerType.LOCAL as const, command: '${lowercase}', }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${lowercase}'); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index b87c8ebad481e..8bcf790a96501 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { authSession: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSession.d.ts', }, + authSessionAudience: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts', + }, authenticationChallenges: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts', }, diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index 6bce710170452..da82630110aee 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -849,9 +849,12 @@ function createKeyboardNavigationEventFilter(keybindingService: IKeybindingServi }; } -export interface IWorkbenchObjectTreeOptions extends IObjectTreeOptions, IResourceNavigatorOptions { - readonly accessibilityProvider: IListAccessibilityProvider; +export interface IWorkbenchObjectTreeOptionsUpdate extends IAbstractTreeOptionsUpdate { readonly overrideStyles?: IStyleOverride; +} + +export interface IWorkbenchObjectTreeOptions extends IObjectTreeOptions, IWorkbenchObjectTreeOptionsUpdate, IResourceNavigatorOptions { + readonly accessibilityProvider: IListAccessibilityProvider; readonly selectionNavigation?: boolean; readonly scrollToActiveElement?: boolean; } @@ -882,8 +885,13 @@ export class WorkbenchObjectTree, TFilterData = void> this.disposables.add(this.internals); } - override updateOptions(options: IAbstractTreeOptionsUpdate): void { + override updateOptions(options: IWorkbenchObjectTreeOptionsUpdate = {}): void { super.updateOptions(options); + + if (options.overrideStyles) { + this.internals.updateStyleOverrides(options.overrideStyles); + } + this.internals.updateOptions(options); } } diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index d3841d18787de..015777c71ddd2 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -584,7 +584,8 @@ export class TerminalSandboxEngine extends Disposable { } const sandboxSettings = this._os === OperatingSystem.Windows ? this._windowsMxcRuntime.createConfig({ command: this._commandLine ?? '', - cwd: this._commandCwd, + shell: this._commandShell, + cwd: this._commandCwd ?? this._getDefaultWindowsMxcCwd(), tempDir: this._tempDir, allowNetwork, networkDomains: this.getResolvedNetworkDomains(), @@ -714,16 +715,14 @@ export class TerminalSandboxEngine extends Disposable { private async _resolveFileSystemPath(path: string): Promise { const expandedPath = this._os === OperatingSystem.Linux ? this._expandHomePath(path) : path; - if (this._os === OperatingSystem.Windows) { - return expandedPath; - } if (!this._isAbsoluteFileSystemPath(expandedPath)) { return expandedPath; } try { const realpath = await this._fileService.realpath(this._toFileSystemResource(expandedPath)); - return realpath?.path && realpath.path !== expandedPath ? realpath.path : expandedPath; + const resolvedPath = realpath ? this._getUriPath(realpath) : undefined; + return resolvedPath && resolvedPath !== expandedPath ? resolvedPath : expandedPath; } catch { return expandedPath; } @@ -734,9 +733,34 @@ export class TerminalSandboxEngine extends Disposable { } private _toFileSystemResource(path: string): URI { + if (this._os === OperatingSystem.Windows) { + return this._toWindowsFileSystemResource(path); + } return this._userHome?.with({ path }) ?? this._tempDir?.with({ path }) ?? this._host.getWriteRoots()[0]?.with({ path }) ?? URI.file(path); } + private _toWindowsFileSystemResource(path: string): URI { + // Normalize Windows separators for URI parsing, e.g. `C:\Users\me` becomes `C:/Users/me`. + const normalizedPath = path.replace(/\\/g, '/'); + // Match UNC paths, e.g. `//server/share/folder` becomes `file://server/share/folder`. + if (/^\/\/[^/]/.test(normalizedPath)) { + const firstPathSeparator = normalizedPath.indexOf('/', 2); + if (firstPathSeparator === -1) { + return URI.from({ scheme: 'file', authority: normalizedPath.slice(2), path: '/' }); + } + return URI.from({ scheme: 'file', authority: normalizedPath.slice(2, firstPathSeparator), path: normalizedPath.slice(firstPathSeparator) || '/' }); + } + // Match drive-letter paths, e.g. `C:/Users/me` becomes `file:///c:/Users/me`. + if (/^[a-zA-Z]:($|\/)/.test(normalizedPath)) { + return URI.from({ scheme: 'file', path: `/${normalizedPath[0].toLowerCase()}${normalizedPath.slice(1)}` }); + } + // Match URI-shaped drive paths, e.g. `/C:/Users/me` becomes `file:///c:/Users/me`. + if (/^\/[a-zA-Z]:($|\/)/.test(normalizedPath)) { + return URI.from({ scheme: 'file', path: `/${normalizedPath[1].toLowerCase()}${normalizedPath.slice(2)}` }); + } + return URI.from({ scheme: 'file', path: normalizedPath }); + } + private _expandHomePath(path: string): string { const userHome = this._userHome?.path; if (!userHome) { @@ -781,6 +805,10 @@ export class TerminalSandboxEngine extends Disposable { return root ? [this._getUriPath(root)] : []; } + private _getDefaultWindowsMxcCwd(): URI | undefined { + return this._host.getWriteRoots()[0]; + } + private _getSandboxConfiguredEnabledValue(): AgentSandboxEnabledValue { return this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off; } diff --git a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts index 740b82034c09f..c8ed2c282b9e5 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts @@ -30,7 +30,7 @@ export interface IWindowsMxcNetworkConfig { export interface IWindowsMxcConfig { version: string; containerId: string; - containment: 'process'; + containment: 'processcontainer'; lifecycle: { destroyOnExit: boolean; preservePolicy: boolean; @@ -42,11 +42,13 @@ export interface IWindowsMxcConfig { disable: boolean; clipboard: 'none'; injection: boolean; + allowWindows: boolean; }; } export interface IWindowsMxcConfigOptions { command: string; + shell?: string; cwd: URI | undefined; tempDir: URI; allowNetwork: boolean; @@ -99,16 +101,19 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand createConfig(options: IWindowsMxcConfigOptions): IWindowsMxcConfig { const tempDirPath = this.toWindowsPath(options.tempDir); + const shell = options.shell + ? this._quoteWindowsCommandLineArgument(options.shell) + : 'pwsh.exe'; return { version: this._configVersion, containerId: 'vscode-terminal-sandbox', - containment: 'process', + containment: 'processcontainer', lifecycle: { destroyOnExit: true, preservePolicy: false, }, process: { - commandLine: options.command, + commandLine: `${shell} -NoProfile -ExecutionPolicy Bypass -Command ${this._quoteWindowsCommandLineArgument(options.command)}`, cwd: options.cwd ? this.toWindowsPath(options.cwd) : tempDirPath, env: [ ...options.env @@ -117,7 +122,7 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand }, filesystem: { readwritePaths: [...new Set([...options.allowWritePaths])], - readonlyPaths: [...new Set([tempDirPath, ...options.allowReadPaths])], + readonlyPaths: [...new Set([tempDirPath, ...(options.shell && win32.isAbsolute(options.shell) ? [win32.dirname(options.shell)] : []), ...options.allowReadPaths])], deniedPaths: options.denyReadPaths, }, network: this._createNetworkConfig(options.allowNetwork, options.networkDomains), @@ -125,6 +130,7 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand disable: false, clipboard: 'none', injection: false, + allowWindows: true }, }; } @@ -163,4 +169,8 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand private _quotePowerShellArgument(value: string): string { return `'${value.replace(/'/g, `''`)}'`; } + + private _quoteWindowsCommandLineArgument(value: string): string { + return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, '$&$&')}"`; + } } diff --git a/src/vs/platform/sandbox/node/sandboxHelper.ts b/src/vs/platform/sandbox/node/sandboxHelper.ts index 4d1a8f1bd4eab..332eeea85f211 100644 --- a/src/vs/platform/sandbox/node/sandboxHelper.ts +++ b/src/vs/platform/sandbox/node/sandboxHelper.ts @@ -55,9 +55,23 @@ export class SandboxHelperService implements ISandboxHelperService { } const env: string[] = []; - const path = getCaseInsensitive(process.env, 'PATH'); - if (typeof path === 'string' && path) { - env.push(`PATH=${path}`); + for (const variable of ['SystemRoot', 'PATH', 'ComSpec', 'PATHEXT', 'PSModulePath']) { + const value = getCaseInsensitive(process.env, variable); + if (typeof value === 'string' && value) { + env.push(`${variable}=${value}`); + } + } + const userProfile = getCaseInsensitive(process.env, 'USERPROFILE'); + if (typeof userProfile === 'string' && userProfile) { + env.push(`USERPROFILE=${userProfile}`); + } + const appData = getCaseInsensitive(process.env, 'APPDATA'); + if (typeof appData === 'string' && appData) { + env.push(`APPDATA=${appData}`); + } + const localAppData = this._getLocalAppData(); + if (typeof localAppData === 'string' && localAppData) { + env.push(`LOCALAPPDATA=${localAppData}`); } const psHome = await this._getPSHome(); @@ -77,6 +91,11 @@ export class SandboxHelperService implements ISandboxHelperService { return powerShellPath ? win32.dirname(powerShellPath) : undefined; } + private _getLocalAppData(): string | undefined { + const localAppData = getCaseInsensitive(process.env, 'LOCALAPPDATA'); + return typeof localAppData === 'string' && localAppData ? localAppData : undefined; + } + private _getTempReadPaths(): string[] { const paths: string[] = []; for (const variable of ['TMP', 'TEMP']) { diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index 62018e949af95..3503c2c9c7c73 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -97,7 +97,17 @@ suite('TerminalSandboxEngine', () => { getWorkspaceStorageReadRoot: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user/workspaceStorage/workspace-id' })), getWriteRoots: () => [URI.from({ scheme: 'file', path: '/c:/workspace' })], getWindowsMxcFilesystemPolicy: () => Promise.resolve({ readonlyPaths: ['C:\\tools\\node', 'C:\\tools\\python', 'C:\\Users\\user\\AppData\\Local\\Programs\\Git', 'C:\\Users\\user\\AppData\\Local\\Temp'], readwritePaths: [] }), - getWindowsMxcEnvironment: () => Promise.resolve(['PATH=C:\\tools\\node;C:\\Windows\\System32', 'PSHOME=C:\\Program Files\\PowerShell\\7']), + getWindowsMxcEnvironment: () => Promise.resolve([ + 'SystemRoot=C:\\Windows', + 'PATH=C:\\tools\\node;C:\\Windows\\System32', + 'ComSpec=C:\\Windows\\System32\\cmd.exe', + 'PATHEXT=.COM;.EXE;.BAT;.CMD;.PS1', + 'PSModulePath=C:\\Users\\user\\Documents\\PowerShell\\Modules;C:\\Program Files\\PowerShell\\Modules', + 'USERPROFILE=C:\\Users\\user', + 'APPDATA=C:\\Users\\user\\AppData\\Roaming', + 'LOCALAPPDATA=C:\\Users\\user\\AppData\\Local', + 'PSHOME=C:\\Program Files\\PowerShell\\7' + ]), ...overrides, }); } @@ -291,7 +301,7 @@ suite('TerminalSandboxEngine', () => { const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); - const wrapped = await engine.wrapCommand('echo hello', false, 'pwsh', URI.from({ scheme: 'file', path: '/c:/workspace' })); + const wrapped = await engine.wrapCommand('echo hello', false, 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', URI.from({ scheme: 'file', path: '/c:/workspace' })); const configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const config = JSON.parse(createdFiles.get(configPath)!); @@ -300,11 +310,18 @@ suite('TerminalSandboxEngine', () => { ok(wrapped.command.startsWith(`& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\x64\\wxc-exec.exe'`), `Expected MXC executable. Actual: ${wrapped.command}`); ok(wrapped.command.includes(` '${configPath}'`), `Expected wrapped command to pass the MXC config path. Actual: ${wrapped.command}`); strictEqual(config.version, '0.4.0-alpha'); - strictEqual(config.containment, 'process'); - strictEqual(config.process.commandLine, 'echo hello'); + strictEqual(config.containment, 'processcontainer'); + strictEqual(config.process.commandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo hello"'); strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/workspace'); strictEqual(config.ui.disable, false); + ok(config.process.env.includes('SystemRoot=C:\\Windows'), 'SystemRoot should be injected into the MXC process env'); ok(config.process.env.includes('PATH=C:\\tools\\node;C:\\Windows\\System32'), 'PATH should be injected into the MXC process env'); + ok(config.process.env.includes('ComSpec=C:\\Windows\\System32\\cmd.exe'), 'ComSpec should be injected into the MXC process env'); + ok(config.process.env.includes('PATHEXT=.COM;.EXE;.BAT;.CMD;.PS1'), 'PATHEXT should be injected into the MXC process env'); + ok(config.process.env.includes('PSModulePath=C:\\Users\\user\\Documents\\PowerShell\\Modules;C:\\Program Files\\PowerShell\\Modules'), 'PSModulePath should be injected into the MXC process env'); + ok(config.process.env.includes('USERPROFILE=C:\\Users\\user'), 'USERPROFILE should be injected into the MXC process env'); + ok(config.process.env.includes('APPDATA=C:\\Users\\user\\AppData\\Roaming'), 'APPDATA should be injected into the MXC process env'); + ok(config.process.env.includes('LOCALAPPDATA=C:\\Users\\user\\AppData\\Local'), 'LOCALAPPDATA should be injected into the MXC process env'); ok(config.process.env.includes('PSHOME=C:\\Program Files\\PowerShell\\7'), 'PSHOME should be injected into the MXC process env'); deepStrictEqual(config.network, { defaultPolicy: 'allow' }); ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/workspace'), 'Workspace should be writable'); @@ -312,6 +329,7 @@ suite('TerminalSandboxEngine', () => { ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path).endsWith('/.test-data/tmp')), 'Sandbox temp dir should be readable through readonly paths'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/app'), 'App root should be readable for MXC'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/tools/node'), 'MXC available tools policy should add tool paths to readonly paths'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/program files/powershell/7'), 'Resolved PowerShell executable directory should be readable'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/programs/git'), 'MXC user profile policy should add user profile paths to readonly paths'); ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'MXC actual temp policy should add host temp path to readonly paths'); ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); @@ -339,6 +357,35 @@ suite('TerminalSandboxEngine', () => { ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); }); + test('resolves Windows filesystem symlinks when writing MXC config', async () => { + enableWindowsSandbox(); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { + allowWrite: ['C:\\configured\\write-link'], + allowRead: ['C:\\configured\\read-link'], + denyRead: ['C:\\configured\\secret-link'], + }); + fileService.setRealpath('/c:/workspace-link', '/c:/real/workspace'); + fileService.setRealpath('/c:/configured/write-link', '/c:/real/configured-write'); + fileService.setRealpath('/c:/configured/read-link', '/c:/real/configured-read'); + fileService.setRealpath('/c:/configured/secret-link', '/c:/real/configured-secret'); + fileService.setRealpath('/c:/tools/node', '/c:/real/tools-node'); + const host = createWindowsHost({ + getWriteRoots: () => [URI.from({ scheme: 'file', path: '/c:/workspace-link' })], + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('echo hello', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/workspace'), 'Workspace write root symlink should be resolved on Windows'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-write'), 'Configured Windows allowWrite symlink should be resolved'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-read'), 'Configured Windows allowRead symlink should be resolved'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/tools-node'), 'Windows policy readonly symlink should be resolved'); + ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-secret'), 'Configured Windows denyRead symlink should be resolved'); + }); + test('wrapCommand uses arm64 MXC executable on Windows arm64', async () => { enableWindowsSandbox(); const host = createWindowsHost({ @@ -352,7 +399,7 @@ suite('TerminalSandboxEngine', () => { const config = JSON.parse(createdFiles.get(configPath)!); strictEqual(wrapped.command, `& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\arm64\\wxc-exec.exe' '${configPath}'`); - strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/users/user/.test-data/tmp'); + strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/workspace'); }); test('wrapCommand rewrites MXC config when Windows command changes', async () => { @@ -360,17 +407,17 @@ suite('TerminalSandboxEngine', () => { const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); - await engine.wrapCommand('echo first', false, 'pwsh'); + await engine.wrapCommand('echo first', false, 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'); let configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const firstCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; - strictEqual(firstCommandLine, 'echo first'); + strictEqual(firstCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo first"'); - await engine.wrapCommand('echo second', false, 'pwsh'); + await engine.wrapCommand('echo second', false, 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'); configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const secondCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; - strictEqual(secondCommandLine, 'echo second'); + strictEqual(secondCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo second"'); }); test('allowNetwork maps to MXC allow network config on Windows', async () => { diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 395381d6d7372..f3d87695868fd 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -63,6 +63,16 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + /** + * Notifies the view whether it is the currently active session in the + * sessions grid. Subclasses may use this to adjust their visual styling + * (e.g. the chat list's background color). The default implementation + * is a no-op. + */ + setActive(_active: boolean): void { + // no-op by default + } + /** * Called by the workbench grid to size this leaf. Sizes {@link element} * to the allocated dimensions and then delegates to {@link doLayout} so diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 8dd5b8d7d9e15..83eed2d8417a9 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -6,7 +6,8 @@ .chat-composite-bar { display: flex; align-items: center; - background-color: var(--chat-bar-background); + background-color: var(--session-view-background); + color: var(--session-view-foreground); padding: 0 4px; height: 35px; flex-shrink: 0; @@ -58,7 +59,6 @@ border-radius: 4px; user-select: none; flex-shrink: 0; - min-width: 44px; max-width: min(200px, 40cqi); } diff --git a/src/vs/sessions/browser/parts/media/sessionsPart.css b/src/vs/sessions/browser/parts/media/sessionsPart.css index f29ff46901426..7f3b72c1363d9 100644 --- a/src/vs/sessions/browser/parts/media/sessionsPart.css +++ b/src/vs/sessions/browser/parts/media/sessionsPart.css @@ -14,6 +14,16 @@ background-color: var(--vscode-agentsPanel-background); } +.monaco-workbench .part.sessionspart .session-view, +.monaco-workbench .part.sessionspart .session-view > .session-view-content { + background-color: var(--session-view-background); + color: var(--session-view-foreground); +} + +.monaco-workbench .part.sessionspart .session-view .interactive-session { + --vscode-interactive-session-foreground: var(--session-view-foreground) !important; +} + .monaco-workbench .part.sessionspart > .content > .monaco-progress-container { top: 0; } diff --git a/src/vs/sessions/browser/parts/mobile/contributions/AGENTS.md b/src/vs/sessions/browser/parts/mobile/contributions/AGENTS.md new file mode 100644 index 0000000000000..3e3791fb72dcd --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/AGENTS.md @@ -0,0 +1,84 @@ +# Mobile Diff Editors + +This document is the seed design note for the mobile file diff editor and mobile multi-file diff editor in Agent Sessions. Keep it intentionally small for now; it can grow toward a fuller user-guide shape as the implementation settles. + +> Quick summary: mobile diff review uses phone-native full-screen overlays instead of desktop panes. The single-file view renders one unified diff. The multi-file view renders changed files in a continuous review surface with file-level and body-level virtualization. + +## Contents + +- [Why](#why) +- [Current Design](#current-design) +- [How It Works](#how-it-works) +- [Body Virtualization](#body-virtualization) +- [Rendering Ideas](#rendering-ideas) + +## Why + +Phone review needs the same capability as desktop review, but not the same presentation. + +- Desktop side-by-side diffs are too wide for phone viewports. +- Desktop auxiliary views are gated off in phone layout. +- Touch review needs full-screen surfaces, sticky context, simple back navigation, and visible controls. +- Large agent sessions need to avoid eager work for files the user has not opened or scrolled to yet. + +## Current Design + +There are two mobile diff surfaces: + +- `MobileDiffView`: a single-file unified diff overlay with optional sibling navigation. +- `MobileMultiDiffView`: a virtualized multi-file unified diff overlay with per-file headers, collapsible file bodies, and lazy loading for visible or near-visible files. + +Both views use a lightweight diff payload: + +```ts +interface IFileDiffViewData { + readonly originalURI: URI | undefined; + readonly modifiedURI: URI | undefined; + readonly identical: boolean; + readonly added: number; + readonly removed: number; +} +``` + +This supports added, deleted, modified, and no-op files without importing desktop multi-diff workbench types into the mobile browser layer. + +## How It Works + +- File content is read from `ITextFileService`, with `IFileService` as a fallback in the multi-file view. +- The multi-file view keeps persistent per-file state, reserves virtual height from known diff stats, and only mounts file sections that intersect the viewport overscan range. +- File content is read, diffed, tokenized, and mounted incrementally as virtualized items become visible. +- Test/demo hosts can pass an async `computeDiff` hook; the Vite mobile multi-diff page uses this to compute diffs in a worker and better mimic VS Code's worker-backed diff environment. +- Virtualization owns mounted range and deterministic height accounting; native CSS owns sticky file-header behavior. +- Keep file sections anchored at their virtual top so headers can use `position: sticky`; do not emulate sticky headers by moving sections on every scroll frame. +- Lazy loading may defer file work, but visible file bodies must never be blank; unloaded or loading bodies need a stable placeholder that remains visible during native scrolling. +- Prefetch can warm one near-boundary file's render data, but it should not mount DOM for that file and visible loads must keep priority over background work. +- Loaded multi-file diff bodies flatten hunk headers and line rows into deterministic body entries, then render only the visible body range plus overscan. +- Line changes are computed with `linesDiffComputers.getDefault()`. +- The result is shaped into unified diff hunks with a small amount of surrounding context. +- Syntax highlighting first tries Monaco tokenization through `tokenizeToString`. +- When no tokenizer is available, a small regex tokenizer provides readable fallback colors. +- Async rendering is guarded by generation counters so stale reads cannot update disposed or navigated-away views. + +## Body Virtualization + +`MobileMultiDiffView` uses two virtualization layers. + +- The outer layer virtualizes file sections and keeps each file's full height in the scroll range. +- The body layer virtualizes hunk headers and line rows within a loaded file. + +The important behavior to preserve is that a large file contributes its full content height to the outer virtual scroll range. File sections stay anchored in that range, and the browser handles sticky file headers. Avoid JS-driven header pinning; it can drift during fast compositor scrolling. + +The body layer should keep reusing cached diff/tokenization data, render only the visible hunk/line slice, and keep height accounting deterministic so outer scroll position remains stable as bodies load. + +One remaining polish item is preserving horizontal scroll state per file when a virtualized section unmounts and remounts. + +## Rendering Ideas + +Useful ideas to borrow from Monaco/editor virtualization: + +- Applied: reuse visible row DOM instead of clearing and rebuilding the whole visible body slice on every range change. +- Applied: batch newly visible row runs, building markup in one pass before inserting it. +- Applied: keep mounted file sections in DOM order without re-appending them on every scroll layout update. +- Prefetch and cache render data for near-visible files, but do not pre-render their DOM. +- Keep loaded rows positioned with absolute `top`; avoid transform-driven scrolling for loaded content because native sticky headers depend on stable section positioning. +- Preserve horizontal scroll state per file across virtualized unmount/remount cycles. diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css new file mode 100644 index 0000000000000..bc174c4977b23 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Mobile Multi-Diff View — stacked multi-file diff with sticky file headers. */ + +/* Override the scroll wrapper: disable horizontal scroll at the container level; + * each file content area scrolls horizontally on its own. */ +.mobile-multi-diff-view .mobile-overlay-scroll { + overflow-x: hidden; + min-width: 0; +} + +/* Prevent the scroll wrapper's children from expanding horizontally */ +.mobile-multi-diff-view .mobile-overlay-scroll > * { + max-width: 100%; +} + +.mobile-multi-diff-virtual-content { + position: relative; + min-width: 0; + width: 100%; +} + +/* -- Top bar (fixed, shows Back + file count) -------------------------------- */ + +.mobile-multi-diff-topbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + min-height: 44px; + padding: 0 8px; + padding-top: env(safe-area-inset-top); + border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); + flex-shrink: 0; + box-sizing: content-box; +} + +.mobile-multi-diff-file-count { + font-size: 13px; + color: var(--vscode-descriptionForeground); + padding-right: 8px; +} + +/* -- File sections ----------------------------------------------------------- */ + +.mobile-multi-diff-file-section { + position: absolute; + left: 0; + right: 0; + min-width: 0; /* Allow flex child to shrink below content width */ +} + +.mobile-multi-diff-file-section:last-child { + border-bottom: none; +} + +/* -- File header ------------------------------------------------------------- */ + +.mobile-multi-diff-file-header { + display: flex; + align-items: center; + gap: 8px; + position: sticky; + top: 0; + height: 44px; + box-sizing: border-box; + padding: 8px 12px; + z-index: 3; + background: var(--vscode-multiDiffEditor-headerBackground); + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-multiDiffEditor-border, transparent)); + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-multiDiffEditor-border, transparent)); + color: var(--vscode-foreground); + font-size: 13px; + line-height: 22px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} + +.mobile-multi-diff-file-chevron.codicon { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + padding: 4px; + border-radius: 3px; + cursor: pointer; +} + +.mobile-multi-diff-file-chevron::before { + font-size: 16px; + line-height: 1; +} + +.mobile-multi-diff-file-header:active { + background: var(--vscode-toolbar-hoverBackground, var(--vscode-multiDiffEditor-headerBackground)); +} + +.mobile-multi-diff-file-chevron:hover { + background: var(--vscode-toolbar-hoverBackground, transparent); +} + +.mobile-multi-diff-file-chevron:focus-visible { + outline: 1px solid var(--vscode-focusBorder, transparent); + outline-offset: -1px; +} + +/* Hide the content area when the section is collapsed */ +.mobile-multi-diff-file-section.collapsed > .mobile-multi-diff-file-content { + display: none; +} + +.mobile-multi-diff-file-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); + letter-spacing: 0.01em; +} + +.mobile-multi-diff-file-dir { + color: var(--vscode-descriptionForeground); + font-weight: 400; +} + +.mobile-multi-diff-file-base { + color: var(--vscode-foreground); + font-weight: 600; +} + +.mobile-multi-diff-file-stats { + display: flex; + gap: 8px; + flex-shrink: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.mobile-multi-diff-stat-added { + color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); +} + +.mobile-multi-diff-stat-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f14c4c); +} + +/* -- File content area ------------------------------------------------------- */ + +.mobile-multi-diff-file-content { + position: absolute; + top: 44px; + left: 0; + right: 0; + font-family: var(--monaco-monospace-font, 'SF Mono', Menlo, Monaco, Consolas, monospace); + font-size: 12px; + line-height: 18px; + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + will-change: transform; + z-index: 1; +} + +.mobile-multi-diff-file-content-placeholder { + overflow-x: hidden; +} + +/* + * Inner wrapper stretches to the widest line so that shorter lines + * still have their background color fill the full scrollable width — + * same as Monaco's isWholeLine decoration behavior. + */ +.mobile-multi-diff-file-content-inner { + position: relative; + min-width: max-content; + width: 100%; +} + +/* Lines must not wrap so they overflow horizontally into the scroll container */ +.mobile-multi-diff-file-content .mobile-diff-line { + white-space: nowrap; +} + +.mobile-multi-diff-file-content .mobile-diff-hunk-header { + line-height: 18px; + white-space: nowrap; +} + +.mobile-multi-diff-file-section .mobile-diff-hunk-header { + position: static; + background: var(--vscode-editor-background, #1e1e1e); +} + +.mobile-multi-diff-file-content .mobile-multi-diff-body-entry { + position: absolute; + left: 0; + right: 0; + width: 100%; + box-sizing: border-box; +} + +.mobile-multi-diff-file-content .mobile-diff-empty-state { + height: 76px; + padding: 0 32px; + box-sizing: border-box; +} + +.mobile-multi-diff-file-content-placeholder .mobile-diff-empty-state { + position: sticky; + top: 44px; +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css index eb5bee04f047d..2230c9b275021 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css @@ -68,6 +68,12 @@ justify-content: center; } +.mobile-overlay-header-info.inline { + flex-direction: row; + align-items: baseline; + gap: 8px; +} + .mobile-overlay-header-title { font-size: 14px; font-weight: 500; @@ -273,8 +279,8 @@ .mobile-diff-hunk-header { display: block; color: var(--vscode-descriptionForeground); - background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); - padding: 4px 16px; + background: var(--vscode-editor-background, #1e1e1e); + padding: 4px 12px; font-size: 11px; user-select: none; -webkit-user-select: none; @@ -302,19 +308,20 @@ .mobile-diff-line-num { flex-shrink: 0; - width: 40px; - padding: 0 8px; + width: 28px; + padding: 0 4px; color: var(--vscode-editorLineNumber-foreground); text-align: right; user-select: none; -webkit-user-select: none; + font-variant-numeric: tabular-nums; } .mobile-diff-gutter { flex-shrink: 0; - width: 16px; + width: 14px; text-align: center; - padding: 0 2px; + padding: 0; user-select: none; -webkit-user-select: none; } @@ -331,7 +338,7 @@ * token color CSS automatically. */ flex: 0 0 auto; - padding: 0 16px 0 4px; + padding: 0 16px 0 2px; user-select: text; -webkit-user-select: text; white-space: pre; diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts new file mode 100644 index 0000000000000..cebe471da0938 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffHelpers.ts @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; + +interface IFileDiffLike { + readonly originalURI: URI | undefined; + readonly modifiedURI: URI | undefined; +} + +/** Hardcoded extension→languageId fallback for common languages. + * + * The agents window does not load language services / built-in language + * extensions yet, so `ILanguageService.guessLanguageIdByFilepathOrFirstLine` + * returns `'unknown'` for everything except a small core set. Once the + * agents window starts loading language services this map becomes a + * pure fallback for the leftover `'unknown'` cases. The IDs match + * VS Code's built-in extension `package.json` contributions. */ +const EXTENSION_LANGUAGE_MAP: Record = { + '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', + '.jsx': 'javascriptreact', + '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', + '.tsx': 'typescriptreact', + '.py': 'python', '.pyw': 'python', + '.java': 'java', + '.c': 'c', '.h': 'c', + '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', + '.cs': 'csharp', + '.go': 'go', + '.rs': 'rust', + '.rb': 'ruby', + '.php': 'php', + '.html': 'html', '.htm': 'html', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.json': 'json', '.jsonc': 'jsonc', + '.md': 'markdown', + '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', + '.yaml': 'yaml', '.yml': 'yaml', + '.xml': 'xml', + '.sql': 'sql', + '.swift': 'swift', + '.kt': 'kotlin', '.kts': 'kotlin', + '.r': 'r', + '.lua': 'lua', + '.dart': 'dart', +}; + +export function resolveMobileDiffLanguageId(languageService: ILanguageService, diff: IFileDiffLike): string { + const uri = diff.modifiedURI ?? diff.originalURI; + if (!uri) { + return 'plaintext'; + } + // `guessLanguageIdByFilepathOrFirstLine` already handles unknown + // URI schemes (like `vscode-agent-host://`) through resource paths + // and basenames for extension matching. + const guessed = languageService.guessLanguageIdByFilepathOrFirstLine(uri); + if (guessed && guessed !== 'unknown') { + return guessed; + } + const name = basename(uri); + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''; + return EXTENSION_LANGUAGE_MAP[ext] ?? 'plaintext'; +} + +/** + * Tokenize a full text and return the per-line HTML (one entry per + * source line, in order). Uses `tokenizeToString` which awaits + * `TokenizationRegistry.getOrCreate(languageId)` — without that, sync + * tokenization returns null highlighting for any language whose + * textmate grammar hasn't been activated yet. + */ +export async function tokenizeFileLines(languageService: ILanguageService, text: string, languageId: string): Promise { + if (!text) { + return ['']; + } + const html = await tokenizeToString(languageService, text, languageId); + const inner = stripTokenizedWrapper(html); + return inner.split('
'); +} + +function stripTokenizedWrapper(html: string): string { + const openTag = '
'; + const closeTag = '
'; + if (html.startsWith(openTag) && html.endsWith(closeTag)) { + return html.slice(openTag.length, html.length - closeTag.length); + } + return html; +} + +export function hasMultipleTokenClasses(lines: readonly string[]): boolean { + for (const line of lines) { + if (line && /class="mtk[2-9]|class="mtk[1-9][0-9]/.test(line)) { + return true; + } + } + return false; +} + +type RegexTokenKind = 'comment' | 'string' | 'keyword' | 'number' | 'default'; + +interface IRegexToken { + start: number; + end: number; + kind: RegexTokenKind; +} + +type LangFamily = 'js' | 'python' | 'css' | 'html' | 'json' | 'shell' | 'generic'; + +const LANG_FAMILY: Record = { + javascript: 'js', javascriptreact: 'js', + typescript: 'js', typescriptreact: 'js', + java: 'js', csharp: 'js', go: 'js', rust: 'js', + cpp: 'js', c: 'js', swift: 'js', kotlin: 'js', dart: 'js', php: 'js', ruby: 'js', + python: 'python', + css: 'css', scss: 'css', less: 'css', + html: 'html', xml: 'html', + json: 'json', jsonc: 'json', + shellscript: 'shell', powershell: 'shell', +}; + +const JS_KEYWORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', + 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', + 'of', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', + 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield', + 'async', 'await', 'from', 'as', 'interface', 'type', 'enum', 'declare', + 'abstract', 'override', 'readonly', 'namespace', 'module', 'public', 'private', 'protected', +]); + +const PY_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', + 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', + 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', + 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield', +]); + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function buildSpan(kind: RegexTokenKind, text: string): string { + if (kind === 'default' || !text) { + return escapeHtml(text); + } + return `${escapeHtml(text)}`; +} + +function regexTokenizeLine(line: string, lang: LangFamily): string { + const tokens: IRegexToken[] = []; + let pos = 0; + const len = line.length; + + while (pos < len) { + let matched = false; + + const commentPfx = lang === 'python' ? '#' : lang === 'shell' ? '#' : '//'; + if (line.startsWith(commentPfx, pos) || (lang === 'generic' && line.startsWith('#', pos))) { + tokens.push({ start: pos, end: len, kind: 'comment' }); + pos = len; + matched = true; + } + + if (!matched && lang !== 'python' && lang !== 'shell' && line.startsWith('/*', pos)) { + const end = line.indexOf('*/', pos + 2); + const tokenEnd = end === -1 ? len : end + 2; + tokens.push({ start: pos, end: tokenEnd, kind: 'comment' }); + pos = tokenEnd; + matched = true; + } + + if (!matched && (lang === 'js') && line[pos] === '`') { + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === '`') { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && (line[pos] === '"' || line[pos] === '\'')) { + const q = line[pos]; + let i = pos + 1; + while (i < len) { + if (line[i] === '\\') { i += 2; continue; } + if (line[i] === q) { i++; break; } + i++; + } + tokens.push({ start: pos, end: i, kind: 'string' }); + pos = i; + matched = true; + } + + if (!matched && /[0-9]/.test(line[pos])) { + const m = line.slice(pos).match(/^0x[0-9a-fA-F]+|^[0-9]+\.?[0-9]*(?:[eE][+-]?[0-9]+)?/); + if (m) { + tokens.push({ start: pos, end: pos + m[0].length, kind: 'number' }); + pos += m[0].length; + matched = true; + } + } + + if (!matched && /[a-zA-Z_$]/.test(line[pos])) { + const m = line.slice(pos).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); + if (m) { + const word = m[0]; + const keywords = lang === 'python' ? PY_KEYWORDS : JS_KEYWORDS; + const kind: RegexTokenKind = keywords.has(word) ? 'keyword' : 'default'; + tokens.push({ start: pos, end: pos + word.length, kind }); + pos += word.length; + matched = true; + } + } + + if (!matched) { + const prevTok = tokens[tokens.length - 1]; + if (prevTok && prevTok.kind === 'default') { + prevTok.end = pos + 1; + } else { + tokens.push({ start: pos, end: pos + 1, kind: 'default' }); + } + pos++; + } + } + + return tokens.map(t => buildSpan(t.kind, line.slice(t.start, t.end))).join(''); +} + +export function regexTokenizeLines(text: string, languageId: string): string[] { + if (!text) { + return ['']; + } + const lang: LangFamily = LANG_FAMILY[languageId] ?? 'generic'; + return text.split(/\r?\n/).map(line => regexTokenizeLine(line, lang)); +} + +export interface IDiffLine { + type: 'context' | 'added' | 'removed'; + lineNum?: number; + text: string; +} + +export interface IDiffHunk { + header: string; + lines: IDiffLine[]; +} + +const CONTEXT_LINES = 3; + +export function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] { + const origLines = original.split(/\r?\n/); + const modLines = modified.split(/\r?\n/); + + const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + computeMoves: false, + }); + + if (result.changes.length === 0) { + return []; + } + + type Sub = { origStart: number; origEnd: number; modStart: number; modEnd: number }; + type Group = { subs: Sub[] }; + const groups: Group[] = []; + for (const change of result.changes) { + const sub: Sub = { + origStart: change.original.startLineNumber, + origEnd: change.original.endLineNumberExclusive, + modStart: change.modified.startLineNumber, + modEnd: change.modified.endLineNumberExclusive, + }; + const last = groups[groups.length - 1]; + const lastSub = last?.subs[last.subs.length - 1]; + if (lastSub && sub.origStart - lastSub.origEnd <= CONTEXT_LINES * 2) { + last!.subs.push(sub); + } else { + groups.push({ subs: [sub] }); + } + } + + const hunks: IDiffHunk[] = []; + for (const group of groups) { + const first = group.subs[0]; + const last = group.subs[group.subs.length - 1]; + const origLeading = Math.max(1, first.origStart - CONTEXT_LINES); + const modLeading = Math.max(1, first.modStart - CONTEXT_LINES); + const origTrailing = Math.min(origLines.length + 1, last.origEnd + CONTEXT_LINES); + const modTrailing = Math.min(modLines.length + 1, last.modEnd + CONTEXT_LINES); + + const lines: IDiffLine[] = []; + + for (let i = origLeading; i < first.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + for (let s = 0; s < group.subs.length; s++) { + const sub = group.subs[s]; + for (let i = sub.origStart; i < sub.origEnd; i++) { + lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' }); + } + for (let i = sub.modStart; i < sub.modEnd; i++) { + lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' }); + } + const next = group.subs[s + 1]; + if (next) { + for (let i = sub.origEnd; i < next.origStart; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + } + } + + for (let i = last.origEnd; i < origTrailing; i++) { + lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); + } + + const origCount = origTrailing - origLeading; + const modCount = modTrailing - modLeading; + hunks.push({ + header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`, + lines, + }); + } + + return hunks; +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts index b20c4947e807e..d47658c7c9048 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts @@ -177,12 +177,11 @@ export class MobileDiffView extends Disposable { const backBtn = DOM.append(header, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement; backBtn.setAttribute('aria-label', localize('diffView.back', "Back")); DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft)); - DOM.append(backBtn, $('span.back-btn-label')).textContent = localize('diffView.backLabel', "Back"); this.viewStore.add(Gesture.addTarget(backBtn)); this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose())); this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose())); - const info = DOM.append(header, $('div.mobile-overlay-header-info')); + const info = DOM.append(header, $('div.mobile-overlay-header-info.inline')); this.titleEl = DOM.append(info, $('div.mobile-overlay-header-title')); this.subtitleEl = DOM.append(info, $('div.mobile-overlay-header-subtitle')); diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts new file mode 100644 index 0000000000000..acd582010c448 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts @@ -0,0 +1,1022 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/mobileOverlayViews.css'; +import './media/mobileMultiDiffView.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { generateTokensCSSForColorMap } from '../../../../../editor/common/languages/supports/tokenization.js'; +import { IFileDiffViewData } from './mobileDiffView.js'; +import { computeUnifiedDiff, hasMultipleTokenClasses, type IDiffHunk, type IDiffLine, regexTokenizeLines, resolveMobileDiffLanguageId, tokenizeFileLines } from './mobileDiffHelpers.js'; +import { computeMobileMultiDiffItemHeight, computeMobileMultiDiffVirtualLayout, type IMobileMultiDiffVirtualItem, type IMobileMultiDiffVirtualItemLayout, type IMobileMultiDiffVirtualizerMetrics } from './mobileMultiDiffVirtualizer.js'; + +const $ = DOM.$; + +const VIRTUALIZER_METRICS: IMobileMultiDiffVirtualizerMetrics = { + fileHeaderHeight: 44, + hunkHeaderHeight: 26, + rowHeight: 18, + bodyVerticalPadding: 0, + placeholderHeight: 76, +}; +const MAX_CONCURRENT_FILE_LOADS = 2; +const MAX_CONCURRENT_PREFETCH_LOADS = 1; +const MIN_PREFETCH_DISTANCE = 2400; +const PREFETCH_VIEWPORT_MULTIPLIER = 4; + +/** + * Data passed to {@link MobileMultiDiffView}. + */ +export interface IMobileMultiDiffViewData { + readonly diffs: readonly IFileDiffViewData[]; + /** Index of the file to scroll to initially. */ + readonly initialIndex?: number; + /** Optional async diff computation override, used by test/demo hosts that can compute diffs off the UI thread. */ + readonly computeDiff?: (originalText: string, modifiedText: string) => Promise; +} + +type MobileMultiDiffFileLoadState = 'idle' | 'loading' | 'loaded' | 'empty' | 'error'; +type MobileMultiDiffFileLoadKind = 'visible' | 'prefetch'; + +type MobileMultiDiffBodyEntry = IMobileMultiDiffBodyHunkEntry | IMobileMultiDiffBodyLineEntry; + +interface IMobileMultiDiffBodyBaseEntry { + readonly top: number; + readonly height: number; +} + +interface IMobileMultiDiffBodyHunkEntry extends IMobileMultiDiffBodyBaseEntry { + readonly type: 'hunk'; + readonly header: string; +} + +interface IMobileMultiDiffBodyLineEntry extends IMobileMultiDiffBodyBaseEntry { + readonly type: 'line'; + readonly line: IDiffLine; +} + +interface IMobileMultiDiffFileRenderData { + readonly bodyEntries: readonly MobileMultiDiffBodyEntry[]; + readonly bodyHeight: number; + readonly maxLineCharacterCount: number; + readonly origLines: readonly string[]; + readonly modLines: readonly string[]; + readonly hasRealTokens: boolean; +} + +interface IMobileMultiDiffFileState { + readonly index: number; + readonly diff: IFileDiffViewData; + section: HTMLElement | undefined; + content: HTMLElement | undefined; + sectionStore: DisposableStore | undefined; + collapsed: boolean; + loadState: MobileMultiDiffFileLoadState; + loadKind: MobileMultiDiffFileLoadKind | undefined; + loadRequestId: number; + readonly estimatedHunkCount: number; + readonly estimatedRowCount: number; + hunkCount: number; + rowCount: number; + renderData: IMobileMultiDiffFileRenderData | undefined; + bodyScrollTop: number; + bodyViewportHeight: number; + fileMessage: HTMLElement | undefined; + bodyInner: HTMLElement | undefined; + readonly renderedBodyRows: Map; + renderedBodyStartIndex: number | undefined; + renderedBodyEndIndex: number | undefined; +} + +/** + * Full-screen overlay for viewing **multiple** file diffs produced by a + * coding agent session on phone viewports. + * + * Files are represented in a single virtual scroll range. Only visible + * file sections are mounted while the user scrolls continuously through + * the full set of changes. + */ +export class MobileMultiDiffView extends Disposable { + + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose: Event = this._onDidDispose.event; + + private readonly viewStore = this._register(new DisposableStore()); + + private disposed = false; + private renderGeneration = 0; + + private scrollWrapper!: HTMLElement; + private virtualContent!: HTMLElement; + private layoutAnimationFrame: number | undefined; + private loadVisibleAnimationFrame: number | undefined; + private prefetchAnimationFrame: number | undefined; + private currentLayout: ReturnType | undefined; + private readonly mountedIndexes = new Set(); + private readonly fileStates: IMobileMultiDiffFileState[]; + + constructor( + workbenchContainer: HTMLElement, + private readonly data: IMobileMultiDiffViewData, + private readonly textFileService: ITextFileService, + private readonly fileService: IFileService, + private readonly languageService: ILanguageService, + ) { + super(); + this.fileStates = data.diffs.map((diff, index) => ({ + index, + diff, + section: undefined, + content: undefined, + sectionStore: undefined, + collapsed: false, + loadState: 'idle', + loadKind: undefined, + loadRequestId: 0, + estimatedHunkCount: diff.identical || diff.added + diff.removed === 0 ? 0 : 1, + estimatedRowCount: diff.added + diff.removed, + hunkCount: 0, + rowCount: 0, + renderData: undefined, + bodyScrollTop: 0, + bodyViewportHeight: 0, + fileMessage: undefined, + bodyInner: undefined, + renderedBodyRows: new Map(), + renderedBodyStartIndex: undefined, + renderedBodyEndIndex: undefined, + })); + this.render(workbenchContainer); + this.renderGeneration++; + this.updateVirtualLayout(); + this.scrollToInitialIndex(); + this.scheduleLoadVisibleFiles(); + } + + private render(workbenchContainer: HTMLElement): void { + // -- Root overlay + const overlay = DOM.append(workbenchContainer, $('div.mobile-overlay-view.mobile-multi-diff-view')); + this.viewStore.add(DOM.addDisposableListener(overlay, DOM.EventType.CONTEXT_MENU, e => e.preventDefault())); + this.viewStore.add(toDisposable(() => overlay.remove())); + + // -- Top bar (fixed) + const topBar = DOM.append(overlay, $('div.mobile-multi-diff-topbar')); + + const backBtn = DOM.append(topBar, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement; + backBtn.setAttribute('aria-label', localize('multiDiffView.back', "Back")); + DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft)); + this.viewStore.add(Gesture.addTarget(backBtn)); + this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose())); + this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose())); + + const fileCount = DOM.append(topBar, $('span.mobile-multi-diff-file-count')); + fileCount.textContent = localize( + 'multiDiffView.fileCount', + "{0} {1}", + this.data.diffs.length, + this.data.diffs.length === 1 ? localize('multiDiffView.file', "file") : localize('multiDiffView.files', "files"), + ); + + // -- Scroll body + const body = DOM.append(overlay, $('div.mobile-overlay-body')); + this.scrollWrapper = DOM.append(body, $('div.mobile-overlay-scroll')); + this.virtualContent = DOM.append(this.scrollWrapper, $('div.mobile-multi-diff-virtual-content')); + this.viewStore.add(DOM.addDisposableListener(this.scrollWrapper, DOM.EventType.SCROLL, () => this.scheduleVirtualLayout(), { passive: true })); + } + + private scrollToInitialIndex(): void { + if (this.data.initialIndex === undefined || this.data.initialIndex <= 0) { + return; + } + + DOM.getWindow(this.scrollWrapper).requestAnimationFrame(() => { + if (this.disposed) { + return; + } + this.scrollWrapper.scrollTop = this.computeVirtualTop(this.data.initialIndex!); + this.updateVirtualLayout(); + this.scheduleLoadVisibleFiles(); + }); + } + + private formatDirSegment(uri: URI): string { + // Take the last 2 directory segments of the parent path to provide + // context without overwhelming the header on narrow phone widths. + const parent = dirname(uri); + const parentPath = parent.path.replace(/^\/+/, ''); + if (!parentPath || parentPath === '.') { + return ''; + } + const segments = parentPath.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return ''; + } + const tail = segments.slice(-2).join('/'); + const prefix = segments.length > 2 ? '…/' : ''; + return `${prefix}${tail}/`; + } + + private renderFileSection(state: IMobileMultiDiffFileState): { section: HTMLElement; content: HTMLElement; store: DisposableStore } { + const diff = state.diff; + const store = new DisposableStore(); + const section = $('div.mobile-multi-diff-file-section'); + section.dataset.index = String(state.index); + + const header = DOM.append(section, $('div.mobile-multi-diff-file-header')); + + const fileNameUri = diff.modifiedURI ?? diff.originalURI; + const fileName = fileNameUri ? basename(fileNameUri) : ''; + const dirPath = fileNameUri ? this.formatDirSegment(fileNameUri) : ''; + + // Chevron acts as the fold toggle. + const chevronEl = DOM.append(header, $('span.mobile-multi-diff-file-chevron', { + role: 'button', + tabindex: '0', + 'aria-expanded': 'true', + })); + chevronEl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevronEl.setAttribute('aria-label', localize('multiDiffView.toggleFile', "Toggle {0}", fileName || localize('multiDiffView.fileFallback', "file"))); + + const nameEl = DOM.append(header, $('span.mobile-multi-diff-file-name')); + if (dirPath) { + DOM.append(nameEl, $('span.mobile-multi-diff-file-dir')).textContent = dirPath; + } + DOM.append(nameEl, $('span.mobile-multi-diff-file-base')).textContent = fileName; + + const statsEl = DOM.append(header, $('span.mobile-multi-diff-file-stats')); + if (!diff.identical) { + if (diff.added) { + DOM.append(statsEl, $('span.mobile-multi-diff-stat-added')).textContent = `+${diff.added}`; + } + if (diff.removed) { + DOM.append(statsEl, $('span.mobile-multi-diff-stat-removed')).textContent = `-${diff.removed}`; + } + } + + // Content area (will be populated async) + const content = DOM.append(section, $('div.mobile-multi-diff-file-content')); + + // Loading placeholder + const loadingEl = DOM.append(content, $('div.mobile-diff-empty-state')); + loadingEl.textContent = localize('multiDiffView.loading', "Loading…"); + + const toggle = (e: UIEvent) => { + e.stopPropagation(); + state.collapsed = !state.collapsed; + section.classList.toggle('collapsed', state.collapsed); + chevronEl.setAttribute('aria-expanded', state.collapsed ? 'false' : 'true'); + chevronEl.classList.remove(...ThemeIcon.asClassNameArray(state.collapsed ? Codicon.chevronDown : Codicon.chevronRight)); + chevronEl.classList.add(...ThemeIcon.asClassNameArray(state.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + this.scheduleVirtualLayout(); + if (!state.collapsed) { + this.scheduleLoadVisibleFiles(); + } + }; + store.add(Gesture.addTarget(header)); + store.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, toggle)); + store.add(DOM.addDisposableListener(header, TouchEventType.Tap, e => { e.preventDefault(); toggle(e); })); + store.add(DOM.addDisposableListener(chevronEl, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(e); + } + })); + + return { section, content, store }; + } + + private ensureFileSection(state: IMobileMultiDiffFileState): HTMLElement { + if (!state.section || !state.content) { + const { section, content, store } = this.renderFileSection(state); + state.section = section; + state.content = content; + state.sectionStore = store; + this.renderCurrentFileContent(state); + } + + return state.section; + } + + private disposeFileSection(state: IMobileMultiDiffFileState): void { + state.sectionStore?.dispose(); + state.sectionStore = undefined; + state.section?.remove(); + state.section = undefined; + state.content = undefined; + this.resetBodyRenderState(state); + } + + private scheduleVirtualLayout(): void { + if (this.disposed) { + return; + } + + if (this.layoutAnimationFrame !== undefined) { + return; + } + + const targetWindow = DOM.getWindow(this.scrollWrapper); + this.layoutAnimationFrame = targetWindow.requestAnimationFrame(() => { + this.layoutAnimationFrame = undefined; + this.updateVirtualLayout(); + }); + } + + private updateVirtualLayout(): void { + if (this.disposed) { + return; + } + + const layout = this.computeCurrentVirtualLayout(); + this.currentLayout = layout; + this.virtualContent.style.height = `${layout.totalHeight}px`; + + const visibleIndexes = new Set(layout.items.map(item => item.index)); + this.abandonOffscreenLoads(visibleIndexes); + for (const index of Array.from(this.mountedIndexes)) { + if (!visibleIndexes.has(index)) { + this.disposeFileSection(this.fileStates[index]); + this.mountedIndexes.delete(index); + } + } + + let previousSection: HTMLElement | undefined; + for (const item of layout.items) { + const state = this.fileStates[item.index]; + const section = this.ensureFileSection(state); + this.applyVirtualLayout(section, state, item); + if (!this.mountedIndexes.has(item.index)) { + this.mountedIndexes.add(item.index); + } + this.ensureFileSectionDomOrder(section, previousSection); + previousSection = section; + } + + this.scheduleLoadVisibleFiles(); + } + + private ensureFileSectionDomOrder(section: HTMLElement, previousSection: HTMLElement | undefined): void { + const referenceNode = previousSection ? previousSection.nextSibling : this.virtualContent.firstChild; + if (section !== referenceNode) { + this.virtualContent.insertBefore(section, referenceNode); + } + } + + private applyVirtualLayout(section: HTMLElement, state: IMobileMultiDiffFileState, item: IMobileMultiDiffVirtualItemLayout): 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); + 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); + } else { + const bodyHeight = Math.max(0, item.renderHeight - VIRTUALIZER_METRICS.fileHeaderHeight); + const placeholderHeight = Math.min( + bodyHeight || VIRTUALIZER_METRICS.placeholderHeight, + Math.max(VIRTUALIZER_METRICS.placeholderHeight, state.bodyViewportHeight), + ); + content.style.height = `${bodyHeight}px`; + content.style.transform = ''; + this.updateFileMessageHeight(state, placeholderHeight); + } + } + + private renderCurrentFileContent(state: IMobileMultiDiffFileState): void { + if (!state.content) { + return; + } + + switch (state.loadState) { + case 'loaded': + this.renderLoadedFileContent(state); + break; + case 'empty': + this.renderFileMessage(state, localize('multiDiffView.noChanges', "No changes in this file.")); + break; + case 'error': + this.renderFileMessage(state, localize('multiDiffView.loadError', "Unable to load changes in this file.")); + break; + case 'idle': + case 'loading': + this.renderFileMessage(state, localize('multiDiffView.loading', "Loading…")); + break; + } + } + + private renderFileMessage(state: IMobileMultiDiffFileState, message: string): void { + if (!state.content) { + return; + } + + DOM.clearNode(state.content); + this.resetBodyRenderState(state); + const empty = DOM.append(state.content, $('div.mobile-diff-empty-state')); + state.fileMessage = empty; + empty.textContent = message; + this.updateFileMessageHeight(state); + } + + private updateFileMessageHeight(state: IMobileMultiDiffFileState, placeholderHeight?: number): void { + if (!state.content) { + return; + } + + const empty = state.fileMessage; + if (!empty || empty.parentElement !== state.content) { + return; + } + + const bodyHeight = Number.parseFloat(state.content.style.height) || VIRTUALIZER_METRICS.placeholderHeight; + const visibleHeight = placeholderHeight ?? Math.min( + bodyHeight, + Math.max(VIRTUALIZER_METRICS.placeholderHeight, state.bodyViewportHeight), + ); + empty.style.height = `${visibleHeight}px`; + } + + private renderLoadedFileContent(state: IMobileMultiDiffFileState): void { + if (!state.content || !state.renderData) { + return; + } + + const bodyOverscan = Math.max(this.scrollWrapper.clientHeight, 480); + const visibleTop = Math.max(0, state.bodyScrollTop - bodyOverscan); + const visibleBottom = Math.min( + state.renderData.bodyHeight, + state.bodyScrollTop + state.bodyViewportHeight + bodyOverscan, + ); + const { startIndex, endIndex } = this.computeVisibleBodyEntryRange(state.renderData.bodyEntries, visibleTop, visibleBottom); + const inner = this.ensureBodyInner(state); + if (state.renderedBodyStartIndex === startIndex && state.renderedBodyEndIndex === endIndex) { + return; + } + + inner.style.height = `${state.renderData.bodyHeight}px`; + inner.style.minWidth = `calc(${state.renderData.maxLineCharacterCount + 8}ch + 64px)`; + + this.reconcileBodyEntries(state, startIndex, endIndex); + state.renderedBodyStartIndex = startIndex; + state.renderedBodyEndIndex = endIndex; + } + + private toVirtualItem(state: IMobileMultiDiffFileState): IMobileMultiDiffVirtualItem { + return { + collapsed: state.collapsed, + state: state.loadState === 'idle' ? 'unloaded' : state.loadState, + estimatedHunkCount: state.estimatedHunkCount, + estimatedRowCount: state.estimatedRowCount, + hunkCount: state.hunkCount, + rowCount: state.rowCount, + }; + } + + private computeCurrentVirtualLayout(): ReturnType { + return computeMobileMultiDiffVirtualLayout(this.fileStates.map(state => this.toVirtualItem(state)), { + viewportHeight: this.scrollWrapper.clientHeight, + scrollTop: this.scrollWrapper.scrollTop, + overscan: Math.max(this.scrollWrapper.clientHeight, 480), + metrics: VIRTUALIZER_METRICS, + }); + } + + private computeVirtualTop(index: number): number { + let top = 0; + const end = Math.min(index, this.fileStates.length); + for (let i = 0; i < end; i++) { + top += computeMobileMultiDiffItemHeight(this.toVirtualItem(this.fileStates[i]), VIRTUALIZER_METRICS); + } + return top; + } + + private scheduleLoadVisibleFiles(): void { + if (this.disposed || this.loadVisibleAnimationFrame !== undefined) { + return; + } + + const targetWindow = DOM.getWindow(this.scrollWrapper); + this.loadVisibleAnimationFrame = targetWindow.requestAnimationFrame(() => { + this.loadVisibleAnimationFrame = undefined; + this.loadVisibleFiles(); + this.schedulePrefetchFile(); + }); + } + + private cancelScheduledLoadVisibleFiles(): void { + if (this.loadVisibleAnimationFrame !== undefined) { + DOM.getWindow(this.scrollWrapper).cancelAnimationFrame(this.loadVisibleAnimationFrame); + this.loadVisibleAnimationFrame = undefined; + } + } + + private schedulePrefetchFile(): void { + if (this.disposed || this.prefetchAnimationFrame !== undefined) { + return; + } + + const targetWindow = DOM.getWindow(this.scrollWrapper); + this.prefetchAnimationFrame = targetWindow.requestAnimationFrame(() => { + this.prefetchAnimationFrame = undefined; + this.prefetchNearFile(); + }); + } + + private cancelScheduledPrefetchFile(): void { + if (this.prefetchAnimationFrame !== undefined) { + DOM.getWindow(this.scrollWrapper).cancelAnimationFrame(this.prefetchAnimationFrame); + this.prefetchAnimationFrame = undefined; + } + } + + private loadVisibleFiles(): void { + if (this.disposed) { + return; + } + + const loadingCount = this.fileStates.reduce((count, state) => count + (state.loadState === 'loading' ? 1 : 0), 0); + if (loadingCount >= MAX_CONCURRENT_FILE_LOADS) { + return; + } + + const layout = this.currentLayout; + if (!layout) { + return; + } + + const viewportTop = this.scrollWrapper.scrollTop; + const viewportBottom = viewportTop + this.scrollWrapper.clientHeight; + + let nextState: IMobileMultiDiffFileState | undefined; + let nextDistance = Number.POSITIVE_INFINITY; + + for (const item of layout.items) { + const state = this.fileStates[item.index]; + if (state.loadState !== 'idle' || state.collapsed) { + continue; + } + const itemTop = item.virtualTop; + const itemBottom = item.virtualTop + item.virtualHeight; + + const distance = itemBottom < viewportTop + ? viewportTop - itemBottom + : itemTop > viewportBottom + ? itemTop - viewportBottom + : 0; + + if (distance < nextDistance) { + nextState = state; + nextDistance = distance; + } + } + + if (nextState) { + this.ensureFileLoaded(nextState, 'visible'); + } + } + + private prefetchNearFile(): void { + if (this.disposed) { + return; + } + + const layout = this.currentLayout; + if (!layout) { + return; + } + + const mountedIndexes = new Set(layout.items.map(item => item.index)); + if (layout.items.some(item => { + const state = this.fileStates[item.index]; + return !state.collapsed && state.loadState === 'idle'; + })) { + return; + } + + const loadingCount = this.fileStates.reduce((count, state) => count + (state.loadState === 'loading' ? 1 : 0), 0); + const prefetchLoadingCount = this.fileStates.reduce((count, state) => count + (state.loadState === 'loading' && state.loadKind === 'prefetch' ? 1 : 0), 0); + if (loadingCount >= MAX_CONCURRENT_FILE_LOADS || prefetchLoadingCount >= MAX_CONCURRENT_PREFETCH_LOADS) { + return; + } + + const viewportTop = this.scrollWrapper.scrollTop; + const viewportBottom = viewportTop + this.scrollWrapper.clientHeight; + const prefetchDistance = Math.max(MIN_PREFETCH_DISTANCE, this.scrollWrapper.clientHeight * PREFETCH_VIEWPORT_MULTIPLIER); + let virtualTop = 0; + let nextState: IMobileMultiDiffFileState | undefined; + let nextDistance = Number.POSITIVE_INFINITY; + + for (const state of this.fileStates) { + const virtualHeight = computeMobileMultiDiffItemHeight(this.toVirtualItem(state), VIRTUALIZER_METRICS); + const virtualBottom = virtualTop + virtualHeight; + if (!mountedIndexes.has(state.index) && !state.collapsed && state.loadState === 'idle') { + const distance = virtualBottom < viewportTop + ? viewportTop - virtualBottom + : virtualTop > viewportBottom + ? virtualTop - viewportBottom + : 0; + + if (distance <= prefetchDistance && distance < nextDistance) { + nextState = state; + nextDistance = distance; + } + } + + virtualTop = virtualBottom; + } + + if (nextState) { + this.ensureFileLoaded(nextState, 'prefetch'); + } + } + + private ensureFileLoaded(state: IMobileMultiDiffFileState, loadKind: MobileMultiDiffFileLoadKind): void { + if (state.loadState !== 'idle') { + return; + } + state.loadState = 'loading'; + state.loadKind = loadKind; + state.loadRequestId++; + this.renderCurrentFileContent(state); + const generation = this.renderGeneration; + const loadRequestId = state.loadRequestId; + void this.loadFileContent(state, generation, loadRequestId).catch(() => { + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + state.loadState = 'error'; + state.loadKind = undefined; + this.renderCurrentFileContent(state); + }).finally(() => { + if (!this.disposed && generation === this.renderGeneration && state.loadRequestId === loadRequestId) { + this.scheduleVirtualLayout(); + } + }); + } + + private isActiveFileLoad(state: IMobileMultiDiffFileState, generation: number, loadRequestId: number): boolean { + return !this.disposed + && generation === this.renderGeneration + && state.loadRequestId === loadRequestId + && state.loadState === 'loading'; + } + + private abandonOffscreenLoads(visibleIndexes: ReadonlySet): void { + for (const state of this.fileStates) { + if (state.loadState !== 'loading' || state.loadKind === 'prefetch' || visibleIndexes.has(state.index)) { + continue; + } + + state.loadRequestId++; + state.loadState = 'idle'; + state.loadKind = undefined; + state.renderData = undefined; + state.hunkCount = 0; + state.rowCount = 0; + this.resetBodyRenderState(state); + this.renderCurrentFileContent(state); + } + } + + private async loadFileContent(state: IMobileMultiDiffFileState, generation: number, loadRequestId: number): Promise { + const diff = state.diff; + if (diff.identical) { + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + state.loadState = 'empty'; + state.loadKind = undefined; + state.renderData = undefined; + state.hunkCount = 0; + state.rowCount = 0; + this.renderCurrentFileContent(state); + return; + } + + const languageId = resolveMobileDiffLanguageId(this.languageService, diff); + + const [originalText, modifiedText] = await Promise.all([ + this.readTextContent(diff.originalURI), + this.readTextContent(diff.modifiedURI), + ]); + + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + + const hunks = await (this.data.computeDiff?.(originalText, modifiedText) ?? Promise.resolve(computeUnifiedDiff(originalText, modifiedText))); + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + + if (hunks.length === 0) { + state.loadState = 'empty'; + state.loadKind = undefined; + state.renderData = undefined; + state.hunkCount = 0; + state.rowCount = 0; + this.renderCurrentFileContent(state); + return; + } + + const [origLineHtml, modLineHtml] = await Promise.all([ + tokenizeFileLines(this.languageService, originalText, languageId), + tokenizeFileLines(this.languageService, modifiedText, languageId), + ]); + + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + + const hasRealTokens = hasMultipleTokenClasses(origLineHtml) || hasMultipleTokenClasses(modLineHtml); + const origLines = hasRealTokens ? origLineHtml : regexTokenizeLines(originalText, languageId); + const modLines = hasRealTokens ? modLineHtml : regexTokenizeLines(modifiedText, languageId); + + if (!this.isActiveFileLoad(state, generation, loadRequestId)) { + return; + } + + state.loadState = 'loaded'; + state.loadKind = undefined; + state.hunkCount = hunks.length; + state.rowCount = hunks.reduce((count, hunk) => count + hunk.lines.length, 0); + const { bodyEntries, bodyHeight, maxLineCharacterCount } = this.createBodyEntries(hunks); + state.renderData = { bodyEntries, bodyHeight, maxLineCharacterCount, origLines, modLines, hasRealTokens }; + this.resetBodyRenderState(state); + this.renderCurrentFileContent(state); + } + + private async readTextContent(resource: URI | undefined): Promise { + if (!resource) { + return ''; + } + + try { + const model = await this.textFileService.read(resource, { acceptTextOnly: true }); + return model.value; + } catch { + try { + const file = await this.fileService.readFile(resource); + return file.value.toString(); + } catch { + return ''; + } + } + } + + private createBodyEntries(hunks: readonly IDiffHunk[]): { bodyEntries: MobileMultiDiffBodyEntry[]; bodyHeight: number; maxLineCharacterCount: number } { + const bodyEntries: MobileMultiDiffBodyEntry[] = []; + let top = 0; + let maxLineCharacterCount = 0; + + for (const hunk of hunks) { + bodyEntries.push({ + type: 'hunk', + header: hunk.header, + top, + height: VIRTUALIZER_METRICS.hunkHeaderHeight, + }); + top += VIRTUALIZER_METRICS.hunkHeaderHeight; + + for (const line of hunk.lines) { + maxLineCharacterCount = Math.max(maxLineCharacterCount, line.text.length); + bodyEntries.push({ + type: 'line', + line, + top, + height: VIRTUALIZER_METRICS.rowHeight, + }); + top += VIRTUALIZER_METRICS.rowHeight; + } + } + + return { bodyEntries, bodyHeight: top, maxLineCharacterCount }; + } + + private computeVisibleBodyEntryRange( + entries: readonly MobileMultiDiffBodyEntry[], + visibleTop: number, + visibleBottom: number, + ): { startIndex: number; endIndex: number } { + if (entries.length === 0 || visibleBottom <= visibleTop) { + return { startIndex: 0, endIndex: 0 }; + } + + const startIndex = this.findFirstBodyEntryEndingAfter(entries, visibleTop); + const endIndex = this.findFirstBodyEntryStartingAtOrAfter(entries, visibleBottom); + return { startIndex, endIndex: Math.max(startIndex, endIndex) }; + } + + private findFirstBodyEntryEndingAfter(entries: readonly MobileMultiDiffBodyEntry[], offset: number): number { + let low = 0; + let high = entries.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (entries[mid].top + entries[mid].height <= offset) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } + + private findFirstBodyEntryStartingAtOrAfter(entries: readonly MobileMultiDiffBodyEntry[], offset: number): number { + let low = 0; + let high = entries.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (entries[mid].top < offset) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } + + private ensureBodyInner(state: IMobileMultiDiffFileState): HTMLElement { + if (state.bodyInner && state.bodyInner.parentElement === state.content) { + return state.bodyInner; + } + + if (!state.content || !state.renderData) { + throw new Error('Cannot render a loaded mobile diff body without content and render data.'); + } + + DOM.clearNode(state.content); + this.resetBodyRenderState(state); + const inner = DOM.append(state.content, $('div.mobile-multi-diff-file-content-inner')); + inner.style.height = `${state.renderData.bodyHeight}px`; + inner.style.minWidth = `calc(${state.renderData.maxLineCharacterCount + 8}ch + 64px)`; + + const colorMap = TokenizationRegistry.getColorMap(); + if (colorMap && state.renderData.hasRealTokens) { + const styleEl = document.createElement('style'); + styleEl.textContent = generateTokensCSSForColorMap(colorMap); + inner.appendChild(styleEl); + } + + state.bodyInner = inner; + return inner; + } + + private resetBodyRenderState(state: IMobileMultiDiffFileState): void { + state.fileMessage = undefined; + state.bodyInner = undefined; + state.renderedBodyRows.clear(); + state.renderedBodyStartIndex = undefined; + state.renderedBodyEndIndex = undefined; + } + + private reconcileBodyEntries(state: IMobileMultiDiffFileState, startIndex: number, endIndex: number): void { + if (!state.bodyInner || !state.renderData) { + return; + } + + for (const [index, element] of Array.from(state.renderedBodyRows)) { + if (index < startIndex || index >= endIndex) { + element.remove(); + state.renderedBodyRows.delete(index); + } + } + + let runStart: number | undefined; + let runEnd = startIndex; + for (let index = startIndex; index < endIndex; index++) { + if (state.renderedBodyRows.has(index)) { + if (runStart !== undefined) { + this.insertBodyEntryRun(state, runStart, runEnd); + runStart = undefined; + } + continue; + } + + runStart ??= index; + runEnd = index + 1; + } + + if (runStart !== undefined) { + this.insertBodyEntryRun(state, runStart, runEnd); + } + } + + private insertBodyEntryRun(state: IMobileMultiDiffFileState, startIndex: number, endIndex: number): void { + if (!state.bodyInner || !state.renderData) { + return; + } + + const htmlParts: string[] = []; + for (let index = startIndex; index < endIndex; index++) { + htmlParts.push(this.renderBodyEntryHtml(index, state.renderData.bodyEntries[index], state.renderData.origLines, state.renderData.modLines)); + } + + const template = document.createElement('template'); + template.innerHTML = htmlParts.join(''); + const insertedElements = Array.from(template.content.children) as HTMLElement[]; + for (const element of insertedElements) { + const index = Number(element.dataset.entryIndex); + if (Number.isFinite(index)) { + state.renderedBodyRows.set(index, element); + } + } + + state.bodyInner.insertBefore(template.content, this.findNextRenderedBodyRow(state, endIndex)); + } + + private findNextRenderedBodyRow(state: IMobileMultiDiffFileState, startIndex: number): HTMLElement | null { + for (let index = startIndex; index < state.renderData!.bodyEntries.length; index++) { + const element = state.renderedBodyRows.get(index); + if (element) { + return element; + } + } + return null; + } + + private renderBodyEntryHtml( + index: number, + entry: MobileMultiDiffBodyEntry, + origLineHtml: readonly string[], + modLineHtml: readonly string[], + ): string { + const style = `top:${entry.top}px;height:${entry.height}px;`; + if (entry.type === 'hunk') { + return `
${this.escapeHtml(entry.header)}
`; + } + + const line = entry.line; + const lineNumber = line.lineNum !== undefined ? String(line.lineNum) : ''; + const gutter = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; + const content = this.getLineHtml(line, origLineHtml, modLineHtml); + + return [ + `
`, + `${this.escapeHtml(lineNumber)}`, + `${this.escapeHtml(gutter)}`, + `${content}`, + '
', + ].join(''); + } + + private getLineHtml(line: IDiffLine, origLineHtml: readonly string[], modLineHtml: readonly string[]): string { + if (line.lineNum !== undefined) { + const source = line.type === 'added' ? modLineHtml : origLineHtml; + const html = source[line.lineNum - 1]; + if (html !== undefined) { + return html; + } + } + return this.escapeHtml(line.text); + } + + private escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, char => { + switch (char) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case '\'': return '''; + default: return char; + } + }); + } + + override dispose(): void { + this.disposed = true; + if (this.layoutAnimationFrame !== undefined) { + DOM.getWindow(this.scrollWrapper).cancelAnimationFrame(this.layoutAnimationFrame); + this.layoutAnimationFrame = undefined; + } + if (this.loadVisibleAnimationFrame !== undefined) { + this.cancelScheduledLoadVisibleFiles(); + } + if (this.prefetchAnimationFrame !== undefined) { + this.cancelScheduledPrefetchFile(); + } + for (const state of this.fileStates) { + this.disposeFileSection(state); + } + this.mountedIndexes.clear(); + this._onDidDispose.fire(); + this.viewStore.dispose(); + super.dispose(); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffVirtualizer.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffVirtualizer.ts new file mode 100644 index 0000000000000..687f4d03fdc4a --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffVirtualizer.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IMobileMultiDiffVirtualizerMetrics { + readonly fileHeaderHeight: number; + readonly hunkHeaderHeight: number; + readonly rowHeight: number; + readonly bodyVerticalPadding: number; + readonly placeholderHeight: number; +} + +export interface IMobileMultiDiffVirtualItem { + readonly collapsed?: boolean; + readonly state: 'unloaded' | 'loading' | 'loaded' | 'empty' | 'error'; + readonly estimatedHunkCount?: number; + readonly estimatedRowCount?: number; + readonly hunkCount?: number; + readonly rowCount?: number; +} + +export interface IMobileMultiDiffVirtualLayoutOptions { + readonly viewportHeight: number; + readonly scrollTop: number; + readonly overscan?: number; + readonly metrics: IMobileMultiDiffVirtualizerMetrics; +} + +export interface IMobileMultiDiffVirtualItemLayout { + readonly index: number; + readonly virtualTop: number; + readonly virtualHeight: number; + readonly renderTop: number; + readonly renderHeight: number; + readonly innerOffset: number; +} + +export interface IMobileMultiDiffVirtualLayout { + readonly totalHeight: number; + readonly items: readonly IMobileMultiDiffVirtualItemLayout[]; +} + +export function computeMobileMultiDiffItemHeight(item: IMobileMultiDiffVirtualItem, metrics: IMobileMultiDiffVirtualizerMetrics): number { + if (item.collapsed) { + return metrics.fileHeaderHeight; + } + + if (item.state !== 'loaded') { + if (item.state === 'unloaded' || item.state === 'loading') { + const estimatedHeight = computeDiffBodyHeight(item.estimatedHunkCount, item.estimatedRowCount, metrics); + if (estimatedHeight !== undefined) { + return metrics.fileHeaderHeight + estimatedHeight; + } + } + return metrics.fileHeaderHeight + metrics.placeholderHeight; + } + + const bodyHeight = computeDiffBodyHeight(item.hunkCount, item.rowCount, metrics); + if (bodyHeight === undefined) { + return metrics.fileHeaderHeight + metrics.placeholderHeight; + } + + return metrics.fileHeaderHeight + bodyHeight; +} + +function computeDiffBodyHeight( + hunkCount: number | undefined, + rowCount: number | undefined, + metrics: IMobileMultiDiffVirtualizerMetrics, +): number | undefined { + const normalizedHunkCount = Math.max(0, hunkCount ?? 0); + const normalizedRowCount = Math.max(0, rowCount ?? 0); + if (normalizedHunkCount === 0 && normalizedRowCount === 0) { + return undefined; + } + + return metrics.bodyVerticalPadding + + normalizedHunkCount * metrics.hunkHeaderHeight + + normalizedRowCount * metrics.rowHeight; +} + +export function computeMobileMultiDiffVirtualLayout( + items: readonly IMobileMultiDiffVirtualItem[], + options: IMobileMultiDiffVirtualLayoutOptions, +): IMobileMultiDiffVirtualLayout { + const viewportHeight = Math.max(0, options.viewportHeight); + const scrollTop = Math.max(0, options.scrollTop); + const overscan = Math.max(0, options.overscan ?? 0); + const visibleStart = Math.max(0, scrollTop - overscan); + const visibleEnd = scrollTop + viewportHeight + overscan; + + let totalHeight = 0; + const visibleItems: IMobileMultiDiffVirtualItemLayout[] = []; + + for (let index = 0; index < items.length; index++) { + const virtualTop = totalHeight; + const virtualHeight = computeMobileMultiDiffItemHeight(items[index], options.metrics); + const virtualBottom = virtualTop + virtualHeight; + totalHeight = virtualBottom; + + if (virtualHeight <= 0 || virtualTop >= visibleEnd || virtualBottom <= visibleStart) { + continue; + } + + const innerOffset = clamp(scrollTop - virtualTop, 0, virtualHeight); + + visibleItems.push({ + index, + virtualTop, + virtualHeight, + renderTop: virtualTop, + renderHeight: virtualHeight, + innerOffset, + }); + } + + return { + totalHeight, + items: visibleItems, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 2e80bac0b0c29..722ab8c9b82cf 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -230,8 +230,8 @@ height: 100% !important; } -/* Hide the session composite bar (Copilot CLI / Approvals / Branch) on phone */ -.agent-sessions-workbench.phone-layout .session-composite-bar { +/* Hide the desktop chat tab strip on phone. */ +.agent-sessions-workbench.phone-layout .chat-composite-bar { display: none; } diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts index b203431bd52d7..5f03862e4a8e9 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts @@ -215,14 +215,24 @@ export class MobileTitlebarPart extends Disposable { added += c.insertions; removed += c.deletions; } - const hasChanges = changes.length > 0 && (added > 0 || removed > 0); + const hasChanges = changes.length > 0; // Hide on welcome / new-chat — no session changes to view there. const visible = hasChanges && !isNewChatRef.value; changesPill.style.display = visible ? '' : 'none'; if (visible) { - changesAddedEl.textContent = `+${added}`; - changesRemovedEl.textContent = `-${removed}`; - changesPill.title = localize('mobileTopBar.changesTooltip', "{0} files changed (+{1} -{2})", changes.length, added, removed); + if (added > 0 || removed > 0) { + changesAddedEl.textContent = `+${added}`; + changesRemovedEl.textContent = `-${removed}`; + changesPill.title = localize('mobileTopBar.changesTooltip', "{0} files changed (+{1} -{2})", changes.length, added, removed); + } else { + changesAddedEl.textContent = changes.length === 1 + ? localize('mobileTopBar.singleFileChanged', "1 file") + : localize('mobileTopBar.filesChangedCount', "{0} files", changes.length); + changesRemovedEl.textContent = ''; + changesPill.title = changes.length === 1 + ? localize('mobileTopBar.singleFileChangedTooltip', "1 file changed") + : localize('mobileTopBar.filesChangedTooltip', "{0} files changed", changes.length); + } } }; this._register(autorun(reader => { diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index e4159295e2750..3fb055135907e 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -11,12 +11,14 @@ import { URI } from '../../../base/common/uri.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { asCssVariable } from '../../../platform/theme/common/colorUtils.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { AbstractChatView, ChatViewKind } from './chatView.js'; import { ChatCompositeBar } from './chatCompositeBar.js'; import { autorun } from '../../../base/common/observable.js'; -import { SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext } from '../../common/contextkeys.js'; +import { SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext } from '../../common/contextkeys.js'; +import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; import { SessionStatus } from '../../services/sessions/common/session.js'; /** @@ -31,6 +33,10 @@ import { SessionStatus } from '../../services/sessions/common/session.js'; export class SessionView extends Disposable implements ISerializableView { static readonly TYPE = 'sessions.sessionView'; + private static readonly ACTIVE_BACKGROUND = asCssVariable(activeSessionViewBackground); + private static readonly ACTIVE_FOREGROUND = asCssVariable(activeSessionViewForeground); + private static readonly INACTIVE_BACKGROUND = asCssVariable(inactiveSessionViewBackground); + private static readonly INACTIVE_FOREGROUND = asCssVariable(inactiveSessionViewForeground); /** Height of the chat composite bar when visible. */ private static readonly BAR_HEIGHT = 35; @@ -56,6 +62,10 @@ export class SessionView extends Disposable implements ISerializableView { private readonly _sessionIsCreatedKey: IContextKey; private readonly _sessionIsStickyKey: IContextKey; private readonly _sessionIsMaximizedKey: IContextKey; + private readonly _sessionSupportsMultipleChatsKey: IContextKey; + + /** Whether this view currently hosts the active session in the grid. */ + private _isActive = true; constructor( @IChatViewFactory private readonly chatViewFactory: IChatViewFactory, @@ -70,6 +80,7 @@ export class SessionView extends Disposable implements ISerializableView { this._sessionIsCreatedKey = SessionIsCreatedContext.bindTo(scopedContextKeyService); this._sessionIsStickyKey = SessionIsStickyContext.bindTo(scopedContextKeyService); this._sessionIsMaximizedKey = SessionIsMaximizedContext.bindTo(scopedContextKeyService); + this._sessionSupportsMultipleChatsKey = SessionSupportsMultipleChatsContext.bindTo(scopedContextKeyService); const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); @@ -78,6 +89,7 @@ export class SessionView extends Disposable implements ISerializableView { this._contentContainer = $('.session-view-content'); this.element.appendChild(this._contentContainer); + this._applyActiveSessionStyles(); // Re-layout children when the composite bar becomes visible/hidden this._register(this._compositeBar.onDidChangeVisibility(() => this._layoutChildren())); @@ -86,7 +98,7 @@ export class SessionView extends Disposable implements ISerializableView { openSession(session: IActiveSession | undefined): void { this._openSessionDisposables.clear(); - this._handleContextKeys(session); + this._openSessionDisposables.add(this._handleContextKeys(session)); this._openSessionDisposables.add(autorun(reader => { let desiredKind: ChatViewKind; @@ -106,6 +118,7 @@ export class SessionView extends Disposable implements ISerializableView { : this.chatViewFactory.createNewChatView(desiredKind === 'newChatInSession'); this._contentContainer.replaceChildren(view.element); this._currentView.value = view; + view.setActive(this._isActive); } if (session) { @@ -121,6 +134,7 @@ export class SessionView extends Disposable implements ISerializableView { if (!session) { this._sessionIsCreatedKey.set(false); this._sessionIsStickyKey.set(false); + this._sessionSupportsMultipleChatsKey.set(false); return Disposable.None; } @@ -133,6 +147,8 @@ export class SessionView extends Disposable implements ISerializableView { this._sessionIsStickyKey.set(session.sticky.read(reader)); })); + this._sessionSupportsMultipleChatsKey.set(session.capabilities.supportsMultipleChats); + return disposables; } @@ -170,4 +186,27 @@ export class SessionView extends Disposable implements ISerializableView { setMaximized(maximized: boolean): void { this._sessionIsMaximizedKey.set(maximized); } + + /** + * Updates whether this view currently hosts the active session in the grid. + * Forwarded to the inner chat view so it can adjust its visual styling + * (e.g. dim the list background for inactive sessions). + */ + setActive(active: boolean): void { + if (this._isActive === active) { + return; + } + this._isActive = active; + this._applyActiveSessionStyles(); + this._currentView.value?.setActive(active); + } + + private _applyActiveSessionStyles(): void { + const background = this._isActive ? SessionView.ACTIVE_BACKGROUND : SessionView.INACTIVE_BACKGROUND; + const foreground = this._isActive ? SessionView.ACTIVE_FOREGROUND : SessionView.INACTIVE_FOREGROUND; + this.element.style.setProperty('--session-view-background', background); + this.element.style.setProperty('--session-view-foreground', foreground); + this.element.style.setProperty('--part-background', background); + this.element.style.setProperty('--part-foreground', foreground); + } } diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 916d7140b4eda..fd2a89b427148 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -236,7 +236,9 @@ export class SessionsPart extends Part { // Mark the active session's element for styling/focus indication. const activeId = active?.sessionId; for (const [key, slot] of this._views) { - slot.view.element.classList.toggle('is-active', key === activeId); + const isActive = key === activeId; + slot.view.element.classList.toggle('is-active', isActive); + slot.view.setActive(isActive); } this._updateContextKeys(visible); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 99d3326bca5e2..31016bad9d3d8 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -639,6 +639,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private _loadPartVisibility(storageService: IStorageService): { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean } { + if (this.layoutPolicy.viewportClass.get() === 'phone') { + return {}; + } + const raw = storageService.get(Workbench._PART_VISIBILITY_KEY, StorageScope.WORKSPACE); if (raw) { try { @@ -652,6 +656,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private _savePartVisibility(): void { + if (this.layoutPolicy.viewportClass.get() === 'phone') { + return; + } + this.storageService.store(Workbench._PART_VISIBILITY_KEY, JSON.stringify({ editor: this.partVisibility.editor, auxiliaryBar: this.partVisibility.auxiliaryBar, diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 6a24992d322cb..238e510a63dd7 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -8,7 +8,7 @@ import { IObservable } from '../../base/common/observable.js'; import { equals } from '../../base/common/objects.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationAgentRef, RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; @@ -111,7 +111,7 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { * an empty array when the session is unknown or no agents have been * advertised. */ - getCustomAgents(sessionId: string): readonly CustomizationAgentRef[]; + getCustomAgents(sessionId: string): readonly AgentCustomization[]; /** * Set (or clear) the selected custom agent for a session. Optional so diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index b7a9e5a9efdde..d6482cd85d5af 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -24,6 +24,7 @@ export const ChatSessionProviderIdContext = new RawContextKey('chatSessi export const SessionIsCreatedContext = new RawContextKey('sessionIsCreated', false, localize('sessionIsCreated', "Whether the session view's session has been created (chat view shown, not new-session view)")); export const SessionIsStickyContext = new RawContextKey('sessionIsSticky', false, localize('sessionIsSticky', "Whether the session view's session is sticky in the grid")); export const SessionIsMaximizedContext = new RawContextKey('sessionIsMaximized', false, localize('sessionIsMaximized', "Whether the session view is currently maximized in the sessions part's grid")); +export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); //#endregion diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index d6ed3b6226915..fcdd0b4eba062 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -162,3 +162,23 @@ export const agentsUnreadBadgeForeground = registerColor( 'agentsUnreadBadge.foreground', ACTIVITY_BAR_BADGE_FOREGROUND, localize('agentsUnreadBadge.foreground', 'Foreground color of the unread sessions count badge on the sidebar toggle.') ); + +export const activeSessionViewBackground = registerColor( + 'activeSessionView.background', agentsPanelBackground, + localize('activeSessionView.background', 'Background color of an active session view in the agent sessions window.') +); + +export const inactiveSessionViewBackground = registerColor( + 'inactiveSessionView.background', agentsChatInputBackground, + localize('inactiveSessionView.background', 'Background color of an inactive session view in the agent sessions window.') +); + +export const activeSessionViewForeground = registerColor( + 'activeSessionView.foreground', agentsPanelForeground, + localize('activeSessionView.foreground', 'Foreground color of an active session view in the agent sessions window.') +); + +export const inactiveSessionViewForeground = registerColor( + 'inactiveSessionView.foreground', agentsPanelForeground, + localize('inactiveSessionView.foreground', 'Foreground color of an inactive session view in the agent sessions window.') +); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index c7ca5203e7002..b3a8f569026e5 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -10,17 +10,19 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { inputBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/theme.js'; import { ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { IChatModelReference, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { IChatSessionsService, localChatSessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { AbstractChatView, ChatViewKind } from '../../../browser/parts/chatView.js'; import { IChat } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatViewPane.js'; import { NewChatInSessionWidget } from './newChatInSessionViewPane.js'; -import { agentsPanelBackground, agentsPanelForeground } from '../../../common/theme.js'; +import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; +import { isEqual } from '../../../../base/common/resources.js'; /** * A session view that hosts a {@link NewChatWidget} — the "new session" UI @@ -87,10 +89,14 @@ export class ChatView extends AbstractChatView { /** Tracks the currently loaded chat resource to avoid redundant reloads. */ private _currentChatResource: URI | undefined; + /** Whether this view currently represents the active session. */ + private _isActive = true; + constructor( @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ILogService private readonly logService: ILogService ) { super(); @@ -115,21 +121,27 @@ export class ChatView extends AbstractChatView { progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask, }, enableImplicitContext: true, - enableWorkingSet: 'explicit', + enableWorkingSet: 'implicit', supportsChangingModes: true, + inputEditorMinLines: 2, + isSessionsWindow: true }, - { - listForeground: agentsPanelForeground, - listBackground: agentsPanelBackground, - overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, - inputEditorBackground: inputBackground, - resultEditorBackground: agentsPanelBackground, - } + this._buildStyles(this._isActive) )); this._widget.render(this.element); this._widget.setVisible(true); } + private _buildStyles(active: boolean) { + return { + listForeground: active ? activeSessionViewForeground : inactiveSessionViewForeground, + listBackground: active ? activeSessionViewBackground : inactiveSessionViewBackground, + overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, + inputEditorBackground: inactiveSessionViewBackground, + resultEditorBackground: agentsPanelBackground, + }; + } + /** The underlying chat widget. */ get widget(): ChatWidget { return this._widget; @@ -139,7 +151,7 @@ export class ChatView extends AbstractChatView { const resource = chat.resource; // Skip loading if we're already showing this chat - if (this._currentChatResource?.toString() === resource.toString()) { + if (isEqual(this._currentChatResource, resource)) { return; } @@ -156,6 +168,7 @@ export class ChatView extends AbstractChatView { return; } this._modelRef.value = ref; + this._updateWidgetLockState(getChatSessionType(ref.object.sessionResource)); this._widget.setModel(ref.object); }, err => { if (!token.isCancellationRequested) { @@ -167,6 +180,20 @@ export class ChatView extends AbstractChatView { }); } + private _updateWidgetLockState(sessionType: string): void { + if (sessionType === localChatSessionType) { + this._widget.unlockFromCodingAgent(); + return; + } + + const contribution = this.chatSessionsService.getChatSessionContribution(sessionType); + if (contribution) { + this._widget.lockToCodingAgent(contribution.name, contribution.displayName, sessionType); + } else { + this._widget.unlockFromCodingAgent(); + } + } + override toJSON(): object { return { type: ChatView.TYPE }; } @@ -178,6 +205,14 @@ export class ChatView extends AbstractChatView { override focus(): void { this._widget.focusInput(); } + + override setActive(active: boolean): void { + if (this._isActive === active) { + return; + } + this._isActive = active; + this._widget.setStyles(this._buildStyles(active)); + } } /** diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index dae63a3b9b02a..7043972e153be 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -63,6 +63,7 @@ export class LayoutController extends Disposable { private readonly _viewStateBySession: ResourceMap; private readonly _workingSets: ResourceMap; private readonly _workingSetSequencer = new Sequencer(); + private readonly _useModalConfigObs; constructor( @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @@ -234,7 +235,7 @@ export class LayoutController extends Disposable { // --- Editor working sets --- - const useModalConfigObs = observableConfigValue<'off' | 'some' | 'all'>('workbench.editor.useModal', 'all', this._configurationService); + this._useModalConfigObs = observableConfigValue<'off' | 'some' | 'all'>('workbench.editor.useModal', 'all', this._configurationService); // Workspace folders — used to defer session switch until workspace is ready const workspaceFoldersObs = observableFromEvent( @@ -264,7 +265,7 @@ export class LayoutController extends Disposable { }); this._register(autorun(reader => { - const useModalConfig = useModalConfigObs.read(reader); + const useModalConfig = this._useModalConfigObs.read(reader); if (useModalConfig === 'all') { return; } @@ -438,17 +439,23 @@ export class LayoutController extends Disposable { : 'empty'; return this._workingSetSequencer.queue(async () => { + // Switching the active session must never reveal the main editor area + // (or restore editors into it) while modal-only mode is in effect — the + // outer autorun already guards against this, but `useModal` may have + // flipped to 'all' between this call being queued and now. + const isModal = this._useModalConfigObs.get() === 'all'; + if (workingSet === 'empty') { await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); return; } - if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (!isModal && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } const result = await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); - if (result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (!isModal && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index d8483f2312295..166cef330cb3c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -28,7 +28,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AICustomizationManagementCommands } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import type { CustomizationAgentRef } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { AgentCustomization } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { type IChatInputPickerOptions, ChatInputPickerActionViewItem } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { Menus } from '../../../../browser/menus.js'; import { IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; @@ -108,13 +108,13 @@ export function agentHostAgentPickerStorageKey(resourceScheme: string): string { * * Takes the agent URI directly (rather than an `ISessionAgentRef`) because * `ISession.mode` only carries the URI — the display name is recovered from - * the resolved {@link CustomizationAgentRef}. + * the resolved {@link AgentCustomization}. */ export function resolveAgentHostAgent( - agents: readonly CustomizationAgentRef[], + agents: readonly AgentCustomization[], selectedAgentUri: string | undefined, storedAgentUri: string | undefined, -): CustomizationAgentRef | undefined { +): AgentCustomization | undefined { if (selectedAgentUri) { const match = agents.find(a => a.uri === selectedAgentUri); if (match) { @@ -125,9 +125,9 @@ export function resolveAgentHostAgent( } interface IAgentPickerDelegate { - readonly currentAgent: IObservable; - readonly currentAgents: () => readonly CustomizationAgentRef[]; - readonly setAgent: (agent: CustomizationAgentRef | undefined) => void; + readonly currentAgent: IObservable; + readonly currentAgents: () => readonly AgentCustomization[]; + readonly setAgent: (agent: AgentCustomization | undefined) => void; readonly sessionResource: () => URI | undefined; } @@ -165,7 +165,7 @@ class AgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { }, }); - const makeAgentAction = (agent: CustomizationAgentRef): IActionWidgetDropdownAction => { + const makeAgentAction = (agent: AgentCustomization): IActionWidgetDropdownAction => { const current = this.delegate.currentAgent.get(); const agentUri = URI.parse(agent.uri); const toolbarActions: IAction[] = [{ @@ -189,7 +189,7 @@ class AgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { category: customCategory, toolbarActions, run: async () => { - this.delegate.setAgent({ uri: agent.uri, name: agent.name, ...(agent.description ? { description: agent.description } : {}) }); + this.delegate.setAgent(agent); if (this.element) { this.renderLabel(this.element); } @@ -280,7 +280,7 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC super(); const factory = (_action: import('../../../../../base/common/actions.js').IAction, _options: import('../../../../../base/browser/ui/actionbar/actionViewItems.js').IActionViewItemOptions, scopedInstantiationService: import('../../../../../platform/instantiation/common/instantiation.js').IInstantiationService) => { - const currentAgent = observableValue('currentAgent', undefined); + const currentAgent = observableValue('currentAgent', undefined); let settingAgentInternally = false; const getProvider = (session: ISession | undefined): IAgentHostSessionsProvider | undefined => { @@ -298,7 +298,7 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC const provider = getProvider(session); return session && provider ? provider.getCustomAgents(session.sessionId) : []; }, - setAgent: (agent: CustomizationAgentRef | undefined) => { + setAgent: (agent: AgentCustomization | undefined) => { const previous = currentAgent.get(); currentAgent.set(agent, undefined); const session = sessionsManagementService.activeSession.get(); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 10ff951e68e4d..a186388b50e81 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../ import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../../../../platform/agentHost/common/changesetUri.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentSelection, CustomizationAgentRef, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentSelection, AgentCustomization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChangesetState, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -1689,7 +1689,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - getCustomAgents(sessionId: string): readonly CustomizationAgentRef[] { + getCustomAgents(sessionId: string): readonly AgentCustomization[] { const sessionState = this._lastSessionStates.get(sessionId); return getEffectiveAgents(sessionState?.customizations); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts index c17e768738c04..cc187dd87e080 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts @@ -5,15 +5,15 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import type { CustomizationAgentRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationType, type AgentCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { agentHostAgentPickerStorageKey, resolveAgentHostAgent } from '../../../../../../platform/agentHost/common/customAgents.js'; suite('agentHostAgentPicker', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const alpha: CustomizationAgentRef = { uri: 'agent://a', name: 'alpha' }; - const beta: CustomizationAgentRef = { uri: 'agent://b', name: 'beta', description: 'b desc' }; - const agents: readonly CustomizationAgentRef[] = [alpha, beta]; + const alpha: AgentCustomization = { type: CustomizationType.Agent, id: 'agent://a', uri: 'agent://a', name: 'alpha' }; + const beta: AgentCustomization = { type: CustomizationType.Agent, id: 'agent://b', uri: 'agent://b', name: 'beta', description: 'b desc' }; + const agents: readonly AgentCustomization[] = [alpha, beta]; suite('agentHostAgentPickerStorageKey', () => { test('builds a per-scheme storage key', () => { @@ -26,10 +26,7 @@ suite('agentHostAgentPicker', () => { suite('resolveAgentHostAgent', () => { test('returns the session-selected agent when its URI is in the list', () => { - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://b', undefined), - { uri: 'agent://b', name: 'beta', description: 'b desc' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://b', undefined), beta); }); test('falls back to the stored URI when the session has no selection', () => { @@ -39,28 +36,20 @@ suite('agentHostAgentPicker', () => { test('returns undefined when neither session nor stored selection matches the list', () => { assert.strictEqual(resolveAgentHostAgent(agents, undefined, 'agent://missing'), undefined); assert.strictEqual(resolveAgentHostAgent(agents, 'agent://missing', undefined), undefined); - assert.strictEqual(resolveAgentHostAgent(agents, 'agent://missing', undefined), undefined); }); test('session selection wins over stored selection', () => { - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://a', 'agent://b'), - { uri: 'agent://a', name: 'alpha' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://a', 'agent://b'), alpha); }); test('falls through to stored URI when the session agent URI is not in the list', () => { // The session's recorded selection is no longer in the effective // agent list (e.g. the customization providing it was removed), // so the stored fallback is consulted. - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://gone', 'agent://a'), - { uri: 'agent://a', name: 'alpha' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://gone', 'agent://a'), alpha); }); test('returns undefined for an empty agent list', () => { - assert.strictEqual(resolveAgentHostAgent([], 'agent://a', 'agent://a'), undefined); assert.strictEqual(resolveAgentHostAgent([], 'agent://a', 'agent://a'), undefined); assert.strictEqual(resolveAgentHostAgent([], undefined, undefined), undefined); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts index a09bdcc9d1108..fc4e7c0369a3d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts @@ -5,15 +5,28 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { CustomizationStatus, type CustomizationAgentRef, type SessionCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, type AgentCustomization, type Customization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -function sc(uri: string, agents?: CustomizationAgentRef[], enabled = true): SessionCustomization { +function sc(uri: string, children?: AgentCustomization[], enabled = true): Customization { return { - customization: { uri, displayName: uri }, + type: CustomizationType.Plugin, + id: uri, + uri, + name: uri, enabled, - status: CustomizationStatus.Loaded, - ...(agents ? { agents } : {}), + load: { kind: CustomizationLoadStatus.Loaded }, + ...(children ? { children } : {}), + }; +} + +function agent(uri: string, name: string, description?: string): AgentCustomization { + return { + type: CustomizationType.Agent, + id: uri, + uri, + name, + ...(description ? { description } : {}), }; } @@ -25,46 +38,46 @@ suite('getEffectiveAgents', () => { assert.deepStrictEqual(getEffectiveAgents([sc('plugin://a'), sc('plugin://b', [])]), []); }); - test('treats undefined `agents` as unknown and empty array as no agents', () => { + test('treats undefined `children` as unknown and empty array as no agents', () => { const result = getEffectiveAgents([ - sc('plugin://a', [{ uri: 'agent://review', name: 'review' }]), + sc('plugin://a', [agent('agent://review', 'review')]), sc('plugin://b', []), ]); - assert.deepStrictEqual(result, [{ uri: 'agent://review', name: 'review' }]); + assert.deepStrictEqual(result, [agent('agent://review', 'review')]); }); test('skips disabled session customizations', () => { const result = getEffectiveAgents([ - sc('plugin://a', [{ uri: 'agent://a', name: 'a' }], false), - sc('plugin://b', [{ uri: 'agent://b', name: 'b' }]), + sc('plugin://a', [agent('agent://a', 'a')], false), + sc('plugin://b', [agent('agent://b', 'b')]), ]); - assert.deepStrictEqual(result, [{ uri: 'agent://b', name: 'b' }]); + assert.deepStrictEqual(result, [agent('agent://b', 'b')]); }); test('de-dupes by uri (first-seen wins)', () => { const result = getEffectiveAgents([ sc('plugin://a', [ - { uri: 'agent://shared', name: 'shared', description: 'from a' }, - { uri: 'agent://only-a', name: 'only-a' }, + agent('agent://shared', 'shared', 'from a'), + agent('agent://only-a', 'only-a'), ]), sc('plugin://b', [ - { uri: 'agent://shared', name: 'shared', description: 'from b' }, - { uri: 'agent://only-b', name: 'only-b' }, + agent('agent://shared', 'shared', 'from b'), + agent('agent://only-b', 'only-b'), ]), ]); assert.deepStrictEqual(result, [ - { uri: 'agent://only-a', name: 'only-a' }, - { uri: 'agent://only-b', name: 'only-b' }, - { uri: 'agent://shared', name: 'shared', description: 'from a' }, + agent('agent://only-a', 'only-a'), + agent('agent://only-b', 'only-b'), + agent('agent://shared', 'shared', 'from a'), ]); }); test('sorts by name, breaking ties by uri', () => { const result = getEffectiveAgents([ sc('plugin://a', [ - { uri: 'agent://z', name: 'beta' }, - { uri: 'agent://x', name: 'beta' }, - { uri: 'agent://y', name: 'alpha' }, + agent('agent://z', 'beta'), + agent('agent://x', 'beta'), + agent('agent://y', 'alpha'), ]), ]); assert.deepStrictEqual(result.map(a => a.uri), ['agent://y', 'agent://x', 'agent://z']); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 69bde488915c1..03f9d4f06b34c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -15,7 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { AgentSession, IAgentHostService, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationStatus, SessionLifecycle, type AgentInfo, type ChangesetSummary, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, SessionLifecycle, type AgentInfo, type ChangesetSummary, type Customization, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, type ChangesetState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -746,34 +746,46 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { uri: 'plugin://session-1', displayName: 'session plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://session-1', + uri: 'plugin://session-1', + name: 'session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://shared', name: 'shared', description: 'from session' }, - { uri: 'agent://session-only', name: 'session-only' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://shared', uri: 'agent://shared', name: 'shared', description: 'from session' }, + { type: CustomizationType.Agent, id: 'agent://session-only', uri: 'agent://session-only', name: 'session-only' }, ], }, { - customization: { uri: 'plugin://session-2', displayName: 'second session plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://session-2', + uri: 'plugin://session-2', + name: 'second session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://another', name: 'another' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://another', uri: 'agent://another', name: 'another' }, // Duplicate URI — must NOT replace the first-seen entry. - { uri: 'agent://shared', name: 'shared (duplicate)' }, + { type: CustomizationType.Agent, id: 'agent://shared-dup', uri: 'agent://shared', name: 'shared (duplicate)' }, ], }, { // Disabled customizations are skipped entirely. - customization: { uri: 'plugin://disabled', displayName: 'disabled plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://disabled', + uri: 'plugin://disabled', + name: 'disabled plugin', enabled: false, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://disabled', name: 'disabled' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://disabled', uri: 'agent://disabled', name: 'disabled' }], }, { - // Customizations with `agents === undefined` are treated as + // Customizations with `children === undefined` are treated as // "unknown" (host not yet finished parsing) and skipped. - customization: { uri: 'plugin://unparsed', displayName: 'unparsed plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://unparsed', + uri: 'plugin://unparsed', + name: 'unparsed plugin', enabled: true, - status: CustomizationStatus.Loading, + load: { kind: CustomizationLoadStatus.Loading }, }], }; // Force a session-state subscription so `_lastSessionStates` gets @@ -783,10 +795,10 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setSessionState('agents-merge', 'copilotcli', fakeState); assert.deepStrictEqual(provider.getCustomAgents(session!.sessionId), [ - { uri: 'agent://another', name: 'another' }, - { uri: 'agent://session-only', name: 'session-only' }, + { type: CustomizationType.Agent, id: 'agent://another', uri: 'agent://another', name: 'another' }, + { type: CustomizationType.Agent, id: 'agent://session-only', uri: 'agent://session-only', name: 'session-only' }, // First-seen wins for the duplicate `agent://shared` URI. - { uri: 'agent://shared', name: 'shared', description: 'from session' }, + { type: CustomizationType.Agent, id: 'agent://shared', uri: 'agent://shared', name: 'shared', description: 'from session' }, ]); }); @@ -803,8 +815,11 @@ suite('LocalAgentHostSessionsProvider', () => { description: '', models: [], customizations: [{ + type: CustomizationType.Plugin, + id: 'plugin://root', uri: 'plugin://root', - displayName: 'root plugin', + name: 'root plugin', + enabled: true, }], } as AgentInfo, ]); @@ -846,13 +861,13 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { - uri: 'plugin://s', - displayName: 'session plugin', - }, + type: CustomizationType.Plugin, + id: 'plugin://s', + uri: 'plugin://s', + name: 'session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://s', name: 's' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://s', uri: 'agent://s', name: 's' }], }], }); assert.ok(fired > afterRoot, 'expected event to fire on session state customization change'); @@ -891,13 +906,16 @@ suite('LocalAgentHostSessionsProvider', () => { // Push a SessionState carrying customizations as if the host had // resolved them and dispatched a SessionCustomizationsChanged. - const customizations = [{ - customization: { uri: 'plugin://new-session', displayName: 'p' }, + const customizations: Customization[] = [{ + type: CustomizationType.Plugin, + id: 'plugin://new-session', + uri: 'plugin://new-session', + name: 'p', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://reviewer', name: 'reviewer' }, - { uri: 'agent://triage', name: 'triage' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://reviewer', uri: 'agent://reviewer', name: 'reviewer' }, + { type: CustomizationType.Agent, id: 'agent://triage', uri: 'agent://triage', name: 'triage' }, ], }]; const state: SessionState = { @@ -916,8 +934,8 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setSessionState(rawId, sessionTypeId, state); assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ - { uri: 'agent://reviewer', name: 'reviewer' }, - { uri: 'agent://triage', name: 'triage' }, + { type: CustomizationType.Agent, id: 'agent://reviewer', uri: 'agent://reviewer', name: 'reviewer' }, + { type: CustomizationType.Agent, id: 'agent://triage', uri: 'agent://triage', name: 'triage' }, ]); assert.ok(fired > 0, 'expected onDidChangeCustomAgents to fire when SessionState arrives'); @@ -928,11 +946,11 @@ suite('LocalAgentHostSessionsProvider', () => { ...state, customizations: [{ ...customizations[0], - agents: [{ uri: 'agent://only', name: 'only' }], + children: [{ type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }], }], }); assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ - { uri: 'agent://only', name: 'only' }, + { type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }, ]); assert.ok(fired > after, 'expected onDidChangeCustomAgents to fire again on a second update'); }); @@ -956,10 +974,13 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { uri: 'plugin://x', displayName: 'p' }, + type: CustomizationType.Plugin, + id: 'plugin://x', + uri: 'plugin://x', + name: 'p', enabled: true, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://x', name: 'x' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://x', uri: 'agent://x', name: 'x' }], }], }); assert.strictEqual(provider.getCustomAgents(first.sessionId).length, 1); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index 5a3f27b2d4d93..73d93eda42d20 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -287,7 +287,7 @@ export function modelPickerStorageKey(sessionType: string): string { export function shouldShowSessionManageModelsAction(sessionsManagementService: ISessionsManagementService): boolean { const session = sessionsManagementService.activeSession.get(); - return session?.providerId === COPILOT_PROVIDER_ID && session.sessionType === SessionType.Local; + return session?.sessionType === SessionType.Local; } function getVendorFromModelIdentifier(modelIdentifier: string): string | undefined { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modelPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modelPickerDelegate.test.ts index 83d0b72ef64ab..c01ca5bff6445 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modelPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modelPickerDelegate.test.ts @@ -170,7 +170,7 @@ suite('shouldShowSessionManageModelsAction', () => { shouldShowSessionManageModelsAction(localServices.get(ISessionsManagementService)), shouldShowSessionManageModelsAction(cliServices.get(ISessionsManagementService)), shouldShowSessionManageModelsAction(otherProviderServices.get(ISessionsManagementService)), - ], [true, false, false]); + ], [true, false, true]); }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 385adb2083ed5..56852642f0335 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -17,15 +17,16 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import type { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { ROOT_STATE_URI, type AgentInfo, type CustomizationRef } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ROOT_STATE_URI, customizationId, type AgentInfo, type Customization } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationSyncProvider, type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; +import { CustomizationType } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -function customizationKey(customization: CustomizationRef): string { +function customizationKey(customization: Customization): string { return customization.uri; } @@ -57,12 +58,12 @@ export class RemoteAgentPluginController extends Disposable { ]; } - async removeConfiguredPlugin(customizationToRemove: CustomizationRef): Promise { + async removeConfiguredPlugin(customizationToRemove: Customization): Promise { const updated = this.getConfiguredCustomizations().filter(customization => customizationKey(customization) !== customizationKey(customizationToRemove)); this.dispatchCustomizations(updated); } - private getConfiguredCustomizations(): readonly CustomizationRef[] { + private getConfiguredCustomizations(): readonly Customization[] { const rootState = this._connection.rootState.value; if (!rootState || rootState instanceof Error) { return []; @@ -71,11 +72,14 @@ export class RemoteAgentPluginController extends Disposable { return getAgentHostConfiguredCustomizations(rootState.config?.values); } - private dispatchCustomizations(customizations: readonly CustomizationRef[]): void { + private dispatchCustomizations(customizations: readonly Customization[]): void { this._connection.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { - [AgentHostConfigKey.Customizations]: [...customizations], + [AgentHostConfigKey.Customizations]: customizations.map(c => ({ + uri: c.uri, + displayName: c.name, + })), }, }); } @@ -103,9 +107,13 @@ export class RemoteAgentPluginController extends Disposable { } const original = fromAgentHostUri(selected); - const newCustomization: CustomizationRef = { - uri: original.toString(), - displayName: basename(original) || original.path, + const uriString = original.toString(); + const newCustomization: Customization = { + type: CustomizationType.Plugin, + id: customizationId(uriString), + uri: uriString, + name: basename(original) || original.path, + enabled: true, }; const current = this.getConfiguredCustomizations(); @@ -114,7 +122,7 @@ export class RemoteAgentPluginController extends Disposable { this._notificationService.info(localize( 'remoteAgentHost.pluginAlreadyConfigured', "'{0}' is already configured on {1}.", - newCustomization.displayName, + newCustomization.name, this._hostLabel, )); return; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index fbb374e1b7438..cdb0d4baa4df6 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -10,7 +10,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, isSessionAction, type ActionEnvelope, type INotification, type StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { CustomizationStatus, type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, type AgentInfo, type Customization, type RootState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -107,7 +107,7 @@ const testSessionResource = URI.parse('agent-host-copilotcli:/session-1'); const agentHostProviderId = 'copilotcli'; const agentHostSessionId = `${agentHostProviderId}:/session-1`; -function createAgentInfo(customizations: readonly CustomizationRef[]): AgentInfo { +function createAgentInfo(customizations: readonly Customization[]): AgentInfo { return { provider: agentHostProviderId, displayName: 'Copilot', @@ -134,16 +134,17 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }; - const pluginB: CustomizationRef = { - uri: 'file:///plugins/other', - displayName: 'Other Plugin', - }; + const pluginA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/shared', uri: 'file:///plugins/shared', name: 'Shared Plugin', enabled: true }; connection.setRootState({ agents: [], config: { schema: { type: 'object', properties: {} }, - values: { customizations: [pluginA, pluginB] }, + values: { + customizations: [ + { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }, + { uri: 'file:///plugins/other', displayName: 'Other Plugin' }, + ], + }, }, }); @@ -154,7 +155,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.RootConfigChanged, config: { - customizations: [pluginB], + customizations: [{ uri: 'file:///plugins/other', displayName: 'Other Plugin' }], }, }, }]); @@ -170,8 +171,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' }; - const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' }; + const pluginA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/a', uri: 'file:///plugins/a', name: 'Plugin A', enabled: true }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/b', uri: 'file:///plugins/b', name: 'Plugin B', enabled: true }; connection.setRootState({ agents: [createAgentInfo([pluginA, pluginB])], @@ -206,11 +207,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const hostScoped: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }; - const synced: SessionCustomization = { - customization: hostScoped, + const hostScoped: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/shared', uri: 'file:///plugins/shared', name: 'Shared Plugin', enabled: true }; + const synced: Customization = { + ...hostScoped, clientId: 'test-client', - enabled: true, }; connection.setRootState({ @@ -256,12 +256,11 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host-plugin', displayName: 'Host Plugin' }; - const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client-plugin', displayName: 'Client Plugin' }; - const synced: SessionCustomization = { - customization: clientPlugin, + const hostPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host-plugin', uri: 'file:///plugins/host-plugin', name: 'Host Plugin', enabled: true }; + const clientPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-plugin', uri: 'file:///plugins/client-plugin', name: 'Client Plugin', enabled: true }; + const synced: Customization = { + ...clientPlugin, clientId: 'test-client', - enabled: true, }; connection.setRootState({ @@ -315,12 +314,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { )); const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`; - const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' }; - const synced: SessionCustomization = { - customization: bundleRef, + const bundleRef: Customization = { type: CustomizationType.Plugin, id: bundleUri, uri: bundleUri, name: 'VS Code Synced Data', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const synced: Customization = { + ...bundleRef, clientId: 'test-client', - enabled: true, - status: CustomizationStatus.Loaded, }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -411,11 +408,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { )); const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`; - const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' }; - const synced: SessionCustomization = { - customization: bundleRef, + const bundleRef: Customization = { type: CustomizationType.Plugin, id: bundleUri, uri: bundleUri, name: 'VS Code Synced Data', enabled: true }; + const synced: Customization = { + ...bundleRef, clientId: 'test-client', - enabled: true, }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -464,12 +460,11 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginRef: CustomizationRef = { uri: 'file:///plugins/my-plugin', displayName: 'My Plugin' }; - const sessionCustomization: SessionCustomization = { - customization: pluginRef, + const pluginRef: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/my-plugin', uri: 'file:///plugins/my-plugin', name: 'My Plugin', enabled: true }; + const sessionCustomization: Customization = { + ...pluginRef, enabled: false, - status: CustomizationStatus.Error, - statusMessage: 'something went wrong', + load: { kind: CustomizationLoadStatus.Error, message: 'something went wrong' }, }; connection.setRootState({ agents: [createAgentInfo([pluginRef])] }); @@ -517,7 +512,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginRef: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' }; + const pluginRef: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host', uri: 'file:///plugins/host', name: 'Host Plugin', enabled: true }; connection.setRootState({ agents: [createAgentInfo([pluginRef])] }); const fileService = new class extends mock() { @@ -543,10 +538,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - customizations: [{ - customization: pluginRef, - enabled: true - }], + customizations: [pluginRef], }, }); @@ -564,8 +556,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' }; - const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client', displayName: 'Client Plugin' }; + const hostPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host', uri: 'file:///plugins/host', name: 'Host Plugin', enabled: true }; + const clientPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client', uri: 'file:///plugins/client', name: 'Client Plugin', enabled: true }; connection.setRootState({ agents: [createAgentInfo([hostPlugin])] }); @@ -590,9 +582,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.SessionCustomizationsChanged, customizations: [{ - customization: clientPlugin, + ...clientPlugin, clientId: 'test-client', - enabled: true }], }, }); @@ -618,15 +609,19 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' }; - const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' }; - const pluginC: CustomizationRef = { uri: 'file:///plugins/c', displayName: 'Plugin C' }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/b', uri: 'file:///plugins/b', name: 'Plugin B', enabled: true }; connection.setRootState({ agents: [], config: { schema: { type: 'object', properties: {} }, - values: { customizations: [pluginA, pluginB, pluginC] }, + values: { + customizations: [ + { uri: 'file:///plugins/a', displayName: 'Plugin A' }, + { uri: 'file:///plugins/b', displayName: 'Plugin B' }, + { uri: 'file:///plugins/c', displayName: 'Plugin C' }, + ], + }, }, }); @@ -638,7 +633,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.RootConfigChanged, config: { - customizations: [pluginA, pluginC], + customizations: [ + { uri: 'file:///plugins/a', displayName: 'Plugin A' }, + { uri: 'file:///plugins/c', displayName: 'Plugin C' }, + ], }, }, }); @@ -655,8 +653,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const clientA: CustomizationRef = { uri: 'file:///plugins/client-a', displayName: 'Client A' }; - const clientB: CustomizationRef = { uri: 'file:///plugins/client-b', displayName: 'Client B' }; + const clientA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-a', uri: 'file:///plugins/client-a', name: 'Client A', enabled: true }; + const clientB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-b', uri: 'file:///plugins/client-b', name: 'Client B', enabled: true }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -681,8 +679,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.SessionCustomizationsChanged, customizations: [ - { customization: clientA, clientId: 'test-client', enabled: true }, - { customization: clientB, clientId: 'test-client', enabled: true }, + { ...clientA, clientId: 'test-client' }, + { ...clientB, clientId: 'test-client' }, ], }, }); @@ -705,7 +703,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' }; + const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/skills-bundle', uri: 'file:///plugins/skills-bundle', name: 'Skills Bundle', enabled: true }; connection.setRootState({ agents: [createAgentInfo([plugin])] }); @@ -779,7 +777,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' }; + const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/skills-bundle', uri: 'file:///plugins/skills-bundle', name: 'Skills Bundle', enabled: true }; connection.setRootState({ agents: [createAgentInfo([plugin])] }); diff --git a/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts b/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts index 1dfb809a31144..9bb98607e1516 100644 --- a/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts @@ -8,14 +8,17 @@ import { Event } from '../../../../../base/common/event.js'; import { registerAction2, Action2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; import { ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; import { IFileDiffViewData, IMobileDiffViewData, MobileDiffView, MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, openMobileDiffView } from '../../../../../sessions/browser/parts/mobile/contributions/mobileDiffView.js'; -import { MobileChangesView, MOBILE_OPEN_CHANGES_VIEW_COMMAND_ID, openMobileChangesView, toRow, rowToDiffData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileChangesView.js'; +import { MOBILE_OPEN_CHANGES_VIEW_COMMAND_ID, toRow, rowToDiffData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileChangesView.js'; +import { MobileMultiDiffView, IMobileMultiDiffViewData } from '../../../../../sessions/browser/parts/mobile/contributions/mobileMultiDiffView.js'; import { IsPhoneLayoutContext } from '../../../../../sessions/common/contextkeys.js'; -import { localize2 } from '../../../../../nls.js'; +import { localize, localize2 } from '../../../../../nls.js'; // Module-level slots for the active overlays so a re-invocation of the // command (e.g. rapid double-tap) closes the prior overlay before opening @@ -24,7 +27,7 @@ import { localize2 } from '../../../../../nls.js'; // so `MutableDisposable.value === undefined` correctly tracks "no overlay // open" — guarding against stale references after self-dispose. const activeDiffView = new MutableDisposable(); -const activeChangesView = new MutableDisposable(); +const activeMultiDiffView = new MutableDisposable(); class MobileOpenDiffViewAction extends Action2 { constructor() { @@ -75,34 +78,47 @@ class MobileOpenChangesViewAction extends Action2 { run(accessor: ServicesAccessor): void { const layoutService = accessor.get(ILayoutService); - const instantiationService = accessor.get(IInstantiationService); - const commandService = accessor.get(ICommandService); + const textFileService = accessor.get(ITextFileService); + const fileService = accessor.get(IFileService); + const languageService = accessor.get(ILanguageService); + const notificationService = accessor.get(INotificationService); const sessionsManagementService = accessor.get(ISessionsManagementService); - // Single-file shortcut: bypass the list when only one change - // exists — opening a list to show one row would be a useless tap. const session = sessionsManagementService.activeSession.get(); const changes = session?.changes.get() ?? []; - if (changes.length === 1) { - const diff = rowToDiffData(toRow(changes[0])); - commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff }); + + // Build per-file diff data, filtering out synthetic aggregate entries + // (entries with no original/modified URIs can't be diffed). + const rows = changes.map(c => toRow(c)); + const diffs: IFileDiffViewData[] = rows + .map(r => rowToDiffData(r)) + .filter(d => d.originalURI || d.modifiedURI); + + if (diffs.length === 0) { + notificationService.info(localize('mobileChangesNotAvailable', "File-level changes are not available for this session yet.")); + return; + } + + // Single-file shortcut: bypass the multi-diff when only one change + // exists — jump straight to the single-file diff view. + if (diffs.length === 1) { + const commandService = accessor.get(ICommandService); + commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff: diffs[0] }); return; } - activeChangesView.value = openMobileChangesView( - instantiationService, + const data: IMobileMultiDiffViewData = { diffs }; + activeMultiDiffView.value = new MobileMultiDiffView( layoutService.mainContainer, - (diff, siblings, index) => { - // Routing through the command keeps the diff overlay - // lifecycle (the `activeDiffView` slot) consistent with - // every other entry point. - commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, { diff, siblings, index } satisfies IMobileDiffViewData); - }, + data, + textFileService, + fileService, + languageService, ); - const view = activeChangesView.value; + const view = activeMultiDiffView.value; Event.once(view.onDidDispose)(() => { - if (activeChangesView.value === view) { - activeChangesView.clear(); + if (activeMultiDiffView.value === view) { + activeMultiDiffView.clear(); } }); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 326d9ecbd9d59..d3d5308d1e2f6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -18,7 +18,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; @@ -200,7 +200,7 @@ registerAction2(class AddChatToSessionBarAction extends Action2 { icon: Codicon.add, menu: { id: Menus.SessionBarInlineToolbar, - when: SessionIsCreatedContext, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext), group: 'navigation', order: 10, }, diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 0805c2056c7dd..5b2a56cfa1300 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -9,7 +9,7 @@ import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { KeybindingLabel } from '../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { Event } from '../../../../../base/common/event.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { isMobile, isWeb, OS } from '../../../../../base/common/platform.js'; +import { isWeb, OS } from '../../../../../base/common/platform.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; @@ -209,7 +209,7 @@ export class SessionsView extends ViewPane { findWidgetContainer, onSessionOpen: (resource, preserveFocus, sideBySide) => { const onOpened = () => { - if (isWeb && isMobile) { + if (isWeb && isPhoneLayout(this.layoutService)) { this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); } }; diff --git a/src/vs/sessions/test/browser/mobileMultiDiffView.test.ts b/src/vs/sessions/test/browser/mobileMultiDiffView.test.ts new file mode 100644 index 0000000000000..7d0fef5c5a4f3 --- /dev/null +++ b/src/vs/sessions/test/browser/mobileMultiDiffView.test.ts @@ -0,0 +1,442 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../base/browser/window.js'; +import { EventType as TouchEventType } from '../../../base/browser/touch.js'; +import { toDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IFileService } from '../../../platform/files/common/files.js'; +import { ILanguageService } from '../../../editor/common/languages/language.js'; +import { ITextFileService } from '../../../workbench/services/textfile/common/textfiles.js'; +import { MobileMultiDiffView } from '../../browser/parts/mobile/contributions/mobileMultiDiffView.js'; +import { IFileDiffViewData } from '../../browser/parts/mobile/contributions/mobileDiffView.js'; + +suite('MobileMultiDiffView', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('loads visible files incrementally instead of batching the initial viewport', async () => { + const fileCount = 100; + const files = new Map(); + const diffs: IFileDiffViewData[] = []; + + for (let i = 0; i < fileCount; i++) { + const originalURI = URI.parse(`inmemory://original/src/file${i}.ts`); + const modifiedURI = URI.parse(`inmemory://modified/src/file${i}.ts`); + files.set(originalURI.toString(), `export const value${i} = ${i};\n`); + files.set(modifiedURI.toString(), `export const value${i} = ${i + 1};\n`); + diffs.push({ + originalURI, + modifiedURI, + identical: false, + added: 1, + removed: 1, + }); + } + + const readUris: string[] = []; + const textFileService = { + read(uri: URI) { + readUris.push(uri.toString()); + return Promise.resolve({ value: files.get(uri.toString()) ?? '' }); + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { diffs }, textFileService, fileService, languageService)); + await animationFrames(2); + + const initialReadCount = readUris.length; + assert.strictEqual(initialReadCount, 2, 'opening the view should load one visible file pair'); + const initialMountedSections = container.querySelectorAll('.mobile-multi-diff-file-section').length; + assert.ok(initialMountedSections > 0, 'opening the view should mount visible file sections'); + assert.ok(initialMountedSections < fileCount, 'opening the view should not mount every file section'); + + const scrollWrapper = container.querySelector('.mobile-overlay-scroll') as HTMLElement | null; + assert.ok(scrollWrapper, 'scroll wrapper should exist'); + const virtualContent = container.querySelector('.mobile-multi-diff-virtual-content') as HTMLElement | null; + assert.ok(virtualContent, 'virtual content should exist'); + + let appendChildCount = 0; + const originalAppendChild = virtualContent.appendChild; + virtualContent.appendChild = function (node: T): T { + appendChildCount++; + return originalAppendChild.call(this, node) as T; + }; + store.add(toDisposable(() => { + virtualContent.appendChild = originalAppendChild; + })); + + scrollWrapper.scrollTop = scrollWrapper.scrollHeight; + scrollWrapper.dispatchEvent(new Event('scroll')); + await animationFrames(2); + + assert.ok(readUris.length > initialReadCount, 'scrolling should load more files'); + assert.ok(readUris.length <= initialReadCount + 4, 'scrolling should load at most one additional file pair per frame'); + const mountedSectionsAfterScroll = container.querySelectorAll('.mobile-multi-diff-file-section').length; + assert.ok(mountedSectionsAfterScroll > 0, 'scrolling should mount file sections for the new viewport'); + assert.ok(mountedSectionsAfterScroll < fileCount, 'scrolling should still not mount every file section'); + + scrollWrapper.scrollTop = 0; + scrollWrapper.dispatchEvent(new Event('scroll')); + await animationFrames(2); + + assert.strictEqual(new Set(readUris).size, readUris.length, 'remounting loaded files should not reread resources'); + assert.strictEqual(appendChildCount, 0, 'scrolling should not reappend mounted file sections'); + + view.dispose(); + }); + + test('uses a larger tappable file header to expand and collapse sections', async () => { + const originalURI = URI.parse('inmemory://original/src/toggle.ts'); + const modifiedURI = URI.parse('inmemory://modified/src/toggle.ts'); + const files = new Map([ + [originalURI.toString(), 'export const value = 1;\n'], + [modifiedURI.toString(), 'export const value = 2;\n'], + ]); + + const textFileService = { + read(uri: URI) { + return Promise.resolve({ value: files.get(uri.toString()) ?? '' }); + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { + diffs: [{ + originalURI, + modifiedURI, + identical: false, + added: 1, + removed: 1, + }] + }, textFileService, fileService, languageService)); + + const section = container.querySelector('.mobile-multi-diff-file-section') as HTMLElement | null; + assert.ok(section, 'file section should exist'); + const header = section.querySelector('.mobile-multi-diff-file-header') as HTMLElement | null; + assert.ok(header, 'file header should exist'); + const chevron = header.querySelector('.mobile-multi-diff-file-chevron') as HTMLElement | null; + assert.ok(chevron, 'file header chevron should exist'); + assert.strictEqual(mainWindow.getComputedStyle(header).height, '44px', 'file header should be a touch-friendly height'); + + header.dispatchEvent(new MouseEvent('click', { bubbles: true })); + assert.ok(section.classList.contains('collapsed'), 'tapping the header should collapse the file section'); + assert.strictEqual(chevron.getAttribute('aria-expanded'), 'false'); + + chevron.dispatchEvent(new MouseEvent('click', { bubbles: true })); + assert.ok(!section.classList.contains('collapsed'), 'tapping the chevron should expand once without bubbling into a second toggle'); + assert.strictEqual(chevron.getAttribute('aria-expanded'), 'true'); + + chevron.dispatchEvent(new Event(TouchEventType.Tap, { bubbles: true, cancelable: true })); + assert.ok(section.classList.contains('collapsed'), 'touch tapping the chevron should collapse through the header target'); + assert.strictEqual(chevron.getAttribute('aria-expanded'), 'false'); + + view.dispose(); + }); + + test('virtualizes rows inside a loaded large file body', async () => { + const lineCount = 200; + const originalURI = URI.parse('inmemory://original/src/large.ts'); + const modifiedURI = URI.parse('inmemory://modified/src/large.ts'); + const originalText = Array.from({ length: lineCount }, (_, i) => `export const fileValue${i} = ${i};`).join('\n'); + const modifiedText = Array.from({ length: lineCount }, (_, i) => `export const fileValue${i} = ${i + 1000};`).join('\n'); + const files = new Map([ + [originalURI.toString(), originalText], + [modifiedURI.toString(), modifiedText], + ]); + + const textFileService = { + read(uri: URI) { + return Promise.resolve({ value: files.get(uri.toString()) ?? '' }); + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { + diffs: [{ + originalURI, + modifiedURI, + identical: false, + added: lineCount, + removed: lineCount, + }] + }, textFileService, fileService, languageService)); + await waitForCondition(() => container.querySelectorAll('.mobile-diff-line').length > 0, 'loaded file should render visible rows'); + + const renderedRows = container.querySelectorAll('.mobile-diff-line').length; + assert.ok(renderedRows < lineCount * 2, 'loaded file should not render every diff row'); + + const bodyInner = container.querySelector('.mobile-multi-diff-file-content-inner') as HTMLElement | null; + assert.ok(bodyInner, 'loaded file should render a stable body wrapper'); + assertEntryOrder(container); + + const scrollWrapper = container.querySelector('.mobile-overlay-scroll') as HTMLElement | null; + assert.ok(scrollWrapper, 'scroll wrapper should exist'); + scrollWrapper.scrollTop = 1200; + scrollWrapper.dispatchEvent(new Event('scroll')); + await waitForCondition(() => container.querySelector('.mobile-multi-diff-file-content-inner') === bodyInner, 'scrolling should keep the same body wrapper'); + + const renderedRowsAfterScroll = container.querySelectorAll('.mobile-diff-line').length; + assert.ok(renderedRowsAfterScroll < lineCount * 2, 'scrolling should keep rendering only the visible diff rows'); + assertEntryOrder(container); + + view.dispose(); + }); + + test('prefetches the next file near a boundary without mounting its section', async () => { + const fileCount = 3; + const lineCount = 200; + const files = new Map(); + const diffs: IFileDiffViewData[] = []; + + for (let i = 0; i < fileCount; i++) { + const originalURI = URI.parse(`inmemory://original/src/prefetch${i}.ts`); + const modifiedURI = URI.parse(`inmemory://modified/src/prefetch${i}.ts`); + files.set(originalURI.toString(), Array.from({ length: lineCount }, (_, line) => `export const value${line} = ${line};`).join('\n')); + files.set(modifiedURI.toString(), Array.from({ length: lineCount }, (_, line) => `export const value${line} = ${line + 1000};`).join('\n')); + diffs.push({ + originalURI, + modifiedURI, + identical: false, + added: lineCount, + removed: lineCount, + }); + } + + const readUris: string[] = []; + const textFileService = { + read(uri: URI) { + readUris.push(uri.toString()); + return Promise.resolve({ value: files.get(uri.toString()) ?? '' }); + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { diffs }, textFileService, fileService, languageService)); + await waitForCondition(() => container.querySelectorAll('.mobile-diff-line').length > 0, 'first file should load before prefetching near its boundary'); + + assert.ok(readUris.some(uri => uri.includes('prefetch0.ts')), 'opening should read the first file'); + assert.ok(!readUris.some(uri => uri.includes('prefetch1.ts')), 'opening should not immediately prefetch the next large file'); + + const scrollWrapper = container.querySelector('.mobile-overlay-scroll') as HTMLElement | null; + assert.ok(scrollWrapper, 'scroll wrapper should exist'); + scrollWrapper.scrollTop = 5000; + scrollWrapper.dispatchEvent(new Event('scroll')); + + await waitForCondition(() => readUris.some(uri => uri.includes('prefetch1.ts')), 'approaching a file boundary should prefetch the next file'); + assert.strictEqual(container.querySelector('.mobile-multi-diff-file-section[data-index="1"]'), null, 'prefetching should not mount the next file section'); + assert.ok(!readUris.some(uri => uri.includes('prefetch2.ts')), 'prefetching should stay bounded to the near file'); + + view.dispose(); + }); + + test('starts loading the newly visible file while an older load is pending', async () => { + const fileCount = 40; + const files = new Map(); + const diffs: IFileDiffViewData[] = []; + + for (let i = 0; i < fileCount; i++) { + const originalURI = URI.parse(`inmemory://original/src/file${i}.ts`); + const modifiedURI = URI.parse(`inmemory://modified/src/file${i}.ts`); + files.set(originalURI.toString(), `export const value${i} = ${i};\n`); + files.set(modifiedURI.toString(), `export const value${i} = ${i + 1};\n`); + diffs.push({ + originalURI, + modifiedURI, + identical: false, + added: 100, + removed: 100, + }); + } + + const readUris: string[] = []; + const pendingReads = new Map>(); + const textFileService = { + read(uri: URI) { + readUris.push(uri.toString()); + const pending = deferred<{ value: string }>(); + pendingReads.set(uri.toString(), pending); + return pending.promise; + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { diffs }, textFileService, fileService, languageService)); + await animationFrames(2); + + assert.ok(readUris.some(uri => uri.includes('file0.ts')), 'opening the view should start loading the first file'); + + const scrollWrapper = container.querySelector('.mobile-overlay-scroll') as HTMLElement | null; + assert.ok(scrollWrapper, 'scroll wrapper should exist'); + scrollWrapper.scrollTop = scrollWrapper.scrollHeight; + scrollWrapper.dispatchEvent(new Event('scroll')); + await animationFrames(3); + + assert.ok(readUris.some(uri => uri.includes(`file${fileCount - 1}.ts`)), 'scrolling should start loading the newly visible file even while the first file is pending'); + + view.dispose(); + resolvePendingReads(pendingReads, files); + }); + + test('keeps an unloaded large file body covered by a sticky loading placeholder', async () => { + const fileCount = 3; + const files = new Map(); + const diffs: IFileDiffViewData[] = []; + + for (let i = 0; i < fileCount; i++) { + const originalURI = URI.parse(`inmemory://original/src/large${i}.ts`); + const modifiedURI = URI.parse(`inmemory://modified/src/large${i}.ts`); + files.set(originalURI.toString(), `export const value${i} = ${i};\n`); + files.set(modifiedURI.toString(), `export const value${i} = ${i + 1};\n`); + diffs.push({ + originalURI, + modifiedURI, + identical: false, + added: 1000, + removed: 1000, + }); + } + + const pendingReads = new Map>(); + const textFileService = { + read(uri: URI) { + const pending = deferred<{ value: string }>(); + pendingReads.set(uri.toString(), pending); + return pending.promise; + } + } as unknown as ITextFileService; + + const fileService = {} as IFileService; + const languageService = { + guessLanguageIdByFilepathOrFirstLine(): string { + return 'typescript'; + } + } as unknown as ILanguageService; + + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const view = store.add(new MobileMultiDiffView(container, { diffs }, textFileService, fileService, languageService)); + await animationFrames(2); + + const scrollWrapper = container.querySelector('.mobile-overlay-scroll') as HTMLElement | null; + assert.ok(scrollWrapper, 'scroll wrapper should exist'); + scrollWrapper.scrollTop = scrollWrapper.scrollHeight; + scrollWrapper.dispatchEvent(new Event('scroll')); + await animationFrames(2); + + const placeholderContent = Array.from(container.querySelectorAll('.mobile-multi-diff-file-content-placeholder')) as HTMLElement[]; + const bottomFileContent = placeholderContent.find(content => Number((content.parentElement as HTMLElement).dataset.index) === fileCount - 1); + assert.ok(bottomFileContent, 'the unloaded file at the new scroll position should render placeholder content'); + assert.strictEqual(bottomFileContent.style.transform, '', 'loading placeholders should not rely on JS scroll transforms'); + assert.ok(bottomFileContent.style.height, 'the placeholder should reserve the file body height'); + + const emptyState = bottomFileContent.querySelector('.mobile-diff-empty-state') as HTMLElement | null; + assert.ok(emptyState, 'the placeholder should contain a loading message'); + assert.ok(emptyState.textContent?.includes('Loading'), 'the placeholder should not be blank'); + assert.ok(emptyState.style.height, 'the placeholder message should reserve visible viewport height'); + assert.strictEqual(mainWindow.getComputedStyle(emptyState).position, 'sticky', 'the loading message should remain visible during native scroll'); + + view.dispose(); + resolvePendingReads(pendingReads, files); + }); +}); + +function animationFrame(): Promise { + return new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); +} + +async function animationFrames(count: number): Promise { + for (let i = 0; i < count; i++) { + await animationFrame(); + } +} + +async function waitForCondition(condition: () => boolean, message: string): Promise { + for (let i = 0; i < 60; i++) { + if (condition()) { + return; + } + await animationFrame(); + } + assert.fail(message); +} + +function assertEntryOrder(container: HTMLElement): void { + const indexes = Array.from(container.querySelectorAll('.mobile-multi-diff-body-entry'), element => Number((element as HTMLElement).dataset.entryIndex)); + assert.deepStrictEqual(indexes, indexes.slice().sort((a, b) => a - b), 'rendered body entries should stay in document order'); +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +function resolvePendingReads(pendingReads: Map>, files: Map): void { + for (const [uri, pending] of pendingReads) { + pending.resolve({ value: files.get(uri) ?? '' }); + } +} diff --git a/src/vs/sessions/test/browser/mobileMultiDiffVirtualizer.test.ts b/src/vs/sessions/test/browser/mobileMultiDiffVirtualizer.test.ts new file mode 100644 index 0000000000000..9d6fcaba4c90c --- /dev/null +++ b/src/vs/sessions/test/browser/mobileMultiDiffVirtualizer.test.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { computeMobileMultiDiffItemHeight, computeMobileMultiDiffVirtualLayout, IMobileMultiDiffVirtualizerMetrics } from '../../browser/parts/mobile/contributions/mobileMultiDiffVirtualizer.js'; + +suite('MobileMultiDiffVirtualizer', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const metrics: IMobileMultiDiffVirtualizerMetrics = { + fileHeaderHeight: 32, + hunkHeaderHeight: 18, + rowHeight: 20, + bodyVerticalPadding: 8, + placeholderHeight: 44, + }; + + test('computes deterministic item heights', () => { + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'loaded', hunkCount: 2, rowCount: 10 }, metrics), + 32 + 8 + 2 * 18 + 10 * 20, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'loading', hunkCount: 2, rowCount: 10 }, metrics), + 32 + 44, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'loaded', collapsed: true, hunkCount: 2, rowCount: 10 }, metrics), + 32, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'loaded', hunkCount: 0, rowCount: 0 }, metrics), + 32 + 44, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'unloaded' }, metrics), + 32 + 44, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'unloaded', estimatedHunkCount: 1, estimatedRowCount: 10 }, metrics), + 32 + 8 + 18 + 10 * 20, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'empty' }, metrics), + 32 + 44, + ); + assert.strictEqual( + computeMobileMultiDiffItemHeight({ state: 'error' }, metrics), + 32 + 44, + ); + }); + + test('handles an empty item list', () => { + const layout = computeMobileMultiDiffVirtualLayout([], { + viewportHeight: 100, + scrollTop: 0, + metrics, + }); + + assert.strictEqual(layout.totalHeight, 0); + assert.deepStrictEqual(layout.items, []); + }); + + test('computes visible items from the outer scroll range', () => { + const items = new Array(5).fill(undefined).map(() => ({ state: 'loaded' as const, hunkCount: 0, rowCount: 2 })); + + const layout = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 100, + scrollTop: 0, + metrics, + }); + + assert.strictEqual(layout.totalHeight, 5 * 80); + assert.deepStrictEqual(layout.items.map(item => item.index), [0, 1]); + assert.deepStrictEqual(layout.items.map(item => item.virtualTop), [0, 80]); + assert.deepStrictEqual(layout.items.map(item => item.innerOffset), [0, 0]); + }); + + test('uses half-open viewport boundaries', () => { + const items = new Array(3).fill(undefined).map(() => ({ state: 'loaded' as const, hunkCount: 0, rowCount: 2 })); + + const firstPage = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 0, + metrics, + }); + const secondPage = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 80, + metrics, + }); + const afterEnd = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 240, + metrics, + }); + + assert.deepStrictEqual(firstPage.items.map(item => item.index), [0]); + assert.deepStrictEqual(secondPage.items.map(item => item.index), [1]); + assert.deepStrictEqual(afterEnd.items.map(item => item.index), []); + }); + + test('includes overscan without changing total height', () => { + const items = new Array(3).fill(undefined).map(() => ({ state: 'loaded' as const, hunkCount: 0, rowCount: 2 })); + + const withoutOverscan = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 80, + metrics, + }); + const withOverscan = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 80, + overscan: 80, + metrics, + }); + + assert.strictEqual(withoutOverscan.totalHeight, 240); + assert.strictEqual(withOverscan.totalHeight, 240); + assert.deepStrictEqual(withoutOverscan.items.map(item => item.index), [1]); + assert.deepStrictEqual(withOverscan.items.map(item => item.index), [0, 1, 2]); + }); + + test('clamps negative scroll, viewport, and overscan values', () => { + const items = new Array(3).fill(undefined).map(() => ({ state: 'loaded' as const, hunkCount: 0, rowCount: 2 })); + + const negativeScroll = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: -100, + metrics, + }); + const negativeViewport = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: -80, + scrollTop: 0, + metrics, + }); + const negativeOverscan = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 80, + scrollTop: 80, + overscan: -80, + metrics, + }); + + assert.deepStrictEqual(negativeScroll.items.map(item => item.index), [0]); + assert.deepStrictEqual(negativeViewport.items.map(item => item.index), []); + assert.deepStrictEqual(negativeOverscan.items.map(item => item.index), [1]); + }); + + test('keeps a large mounted item anchored while computing its inner offset', () => { + const items = [ + { state: 'loaded' as const, hunkCount: 1, rowCount: 10 }, // 258px + { state: 'loaded' as const, hunkCount: 0, rowCount: 2 }, // 80px + ]; + + const insideLargeFile = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 100, + scrollTop: 50, + metrics, + }); + + assert.strictEqual(insideLargeFile.totalHeight, 338); + assert.deepStrictEqual(insideLargeFile.items.map(item => item.index), [0]); + assert.strictEqual(insideLargeFile.items[0].virtualHeight, 258); + assert.strictEqual(insideLargeFile.items[0].renderHeight, 258); + assert.strictEqual(insideLargeFile.items[0].innerOffset, 50); + assert.strictEqual(insideLargeFile.items[0].renderTop, 0); + + const leavingLargeFile = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 100, + scrollTop: 220, + metrics, + }); + + assert.deepStrictEqual(leavingLargeFile.items.map(item => item.index), [0, 1]); + assert.strictEqual(leavingLargeFile.items[0].innerOffset, 220); + assert.strictEqual(leavingLargeFile.items[0].renderTop, 0); + assert.strictEqual(leavingLargeFile.items[0].renderHeight, 258); + assert.strictEqual(leavingLargeFile.items[1].innerOffset, 0); + assert.strictEqual(leavingLargeFile.items[1].renderTop, 258); + }); + + test('uses collapsed heights in total and visible range calculations', () => { + const items = [ + { state: 'loaded' as const, hunkCount: 1, rowCount: 10, collapsed: true }, + { state: 'loaded' as const, hunkCount: 0, rowCount: 2 }, + { state: 'loading' as const }, + ]; + + const layout = computeMobileMultiDiffVirtualLayout(items, { + viewportHeight: 100, + scrollTop: 0, + metrics, + }); + + assert.strictEqual(layout.totalHeight, 32 + 80 + 76); + assert.deepStrictEqual(layout.items.map(item => item.index), [0, 1]); + assert.deepStrictEqual(layout.items.map(item => item.virtualHeight), [32, 80]); + }); +}); diff --git a/src/vs/sessions/test/browser/mobileSessionsPart.test.ts b/src/vs/sessions/test/browser/mobileSessionsPart.test.ts new file mode 100644 index 0000000000000..228fdd18911d7 --- /dev/null +++ b/src/vs/sessions/test/browser/mobileSessionsPart.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { MobileSessionsPart } from '../../browser/parts/mobile/mobileSessionsPart.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; + +suite('Sessions - Mobile Sessions Part', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('layouts the internal session grid at full phone dimensions', () => { + let layoutContentsArgs: readonly [number, number] | undefined; + let gridLayoutArgs: readonly [number, number, number, number] | undefined; + + const part = { + layoutService: { + mainContainer: { + classList: { + contains: (className: string) => className === 'phone-layout', + }, + }, + isVisible: (partId: string) => partId === Parts.SESSIONS_PART, + }, + layoutContents: (width: number, height: number) => { + layoutContentsArgs = [width, height]; + return { + contentSize: { width: width - 2, height: height - 4 }, + }; + }, + _gridWidget: { + layout: (width: number, height: number, top: number, left: number) => { + gridLayoutArgs = [width, height, top, left]; + }, + }, + }; + + MobileSessionsPart.prototype.layout.call(part as unknown as MobileSessionsPart, 390, 796, 48, 0); + + assert.deepStrictEqual(layoutContentsArgs, [390, 796]); + assert.deepStrictEqual(gridLayoutArgs, [388, 792, 48, 0]); + }); +}); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 4be0f8101b029..bdfe9c32a2039 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -15,6 +15,14 @@ interface IWorkbenchTestHarness { panel: boolean; sessions: boolean; }; + layoutPolicy: { + viewportClass: { + get(): 'phone' | 'tablet' | 'desktop'; + }; + }; + storageService: { + store(...args: unknown[]): void; + }; _editorMaximized: boolean; _restoreAttachedEditorMaximizedOnShow: boolean; setEditorMaximized(maximized: boolean): void; @@ -28,6 +36,8 @@ suite('Sessions - Workbench', () => { const rememberAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'rememberAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const setAuxiliaryBarHidden = Reflect.get(Workbench.prototype, 'setAuxiliaryBarHidden') as (this: IWorkbenchTestHarness, hidden: boolean) => void; + const loadPartVisibility = Reflect.get(Workbench.prototype, '_loadPartVisibility') as (this: IWorkbenchTestHarness, storageService: { get(): string | undefined; remove(): void }) => { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean }; + const savePartVisibility = Reflect.get(Workbench.prototype, '_savePartVisibility') as (this: IWorkbenchTestHarness) => void; function createWorkbenchHarness(): IWorkbenchTestHarness { return { @@ -38,6 +48,14 @@ suite('Sessions - Workbench', () => { panel: false, sessions: true, }, + layoutPolicy: { + viewportClass: { + get: () => 'desktop', + }, + }, + storageService: { + store: () => { }, + }, _editorMaximized: false, _restoreAttachedEditorMaximizedOnShow: false, setEditorMaximized: () => { }, @@ -121,4 +139,48 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual(maximizedStates, []); assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); }); + + test('does not restore saved desktop part visibility on phone layout', () => { + let getCalled = false; + const workbench = createWorkbenchHarness(); + workbench.layoutPolicy.viewportClass.get = () => 'phone'; + const storageService = { + get: () => { + getCalled = true; + return JSON.stringify({ editor: true, auxiliaryBar: true, sidebar: true }); + }, + remove: () => { }, + }; + + const restored = loadPartVisibility.call(workbench, storageService); + + assert.deepStrictEqual(restored, {}); + assert.strictEqual(getCalled, false); + }); + + test('restores saved desktop part visibility outside phone layout', () => { + const workbench = createWorkbenchHarness(); + workbench.layoutPolicy.viewportClass.get = () => 'desktop'; + const storageService = { + get: () => JSON.stringify({ editor: true, auxiliaryBar: false, sidebar: false }), + remove: () => { }, + }; + + const restored = loadPartVisibility.call(workbench, storageService); + + assert.deepStrictEqual(restored, { editor: true, auxiliaryBar: false, sidebar: false }); + }); + + test('does not persist part visibility on phone layout', () => { + let storeCalled = false; + const workbench = createWorkbenchHarness(); + workbench.layoutPolicy.viewportClass.get = () => 'phone'; + workbench.storageService.store = () => { + storeCalled = true; + }; + + savePartVisibility.call(workbench); + + assert.strictEqual(storeCalled, false); + }); }); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index ea004302999f8..cc1c5e1812d42 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -109,6 +109,8 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly anonymous = false; readonly anonymousObs: IObservable = observableValue('anonymous', false); + acceptQuotas(): void { } + clearQuotas(): void { } markAnonymousRateLimited(): void { } markSetupCompleted(): void { } setForceHidden(_hidden: boolean): void { } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index c5bc15deaf9e1..e3c082b0ff46e 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -96,6 +96,7 @@ import './mainThreadMcp.js'; import './mainThreadChatContext.js'; import './mainThreadChatDebug.js'; import './mainThreadChatStatus.js'; +import './mainThreadChatQuota.js'; import './mainThreadChatInputNotification.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index a675dafe9347f..710009f88b36e 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -24,11 +24,15 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js'; import { IURLService } from '../../../platform/url/common/url.js'; import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; -import { IAuthorizationTokenResponse } from '../../../base/common/oauth.js'; +import { fetchAuthorizationServerMetadata, IAuthorizationTokenResponse } from '../../../base/common/oauth.js'; import { IDynamicAuthenticationProviderStorageService } from '../../services/authentication/common/dynamicAuthenticationProviderStorage.js'; import { IClipboardService } from '../../../platform/clipboard/common/clipboardService.js'; import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; +import { ISecretStorageService } from '../../../platform/secrets/common/secrets.js'; +import { mcpOAuthClientSecretStorageKey } from '../../contrib/mcp/common/mcpTypes.js'; import { IProductService } from '../../../platform/product/common/productService.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IMcpEnterpriseManagedAuthIdpConfig, mcpEnterpriseManagedAuthIdpSection } from '../../contrib/mcp/common/mcpConfiguration.js'; export interface AuthenticationInteractiveOptions { detail?: string; @@ -130,7 +134,9 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu @IURLService private readonly urlService: IURLService, @IDynamicAuthenticationProviderStorageService private readonly dynamicAuthProviderStorageService: IDynamicAuthenticationProviderStorageService, @IClipboardService private readonly clipboardService: IClipboardService, - @IQuickInputService private readonly quickInputService: IQuickInputService + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ISecretStorageService private readonly secretStorageService: ISecretStorageService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication); @@ -178,6 +184,37 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu clientSecret, initialTokens ); + }, + createXaa: async (issuer) => { + // XAA providers are keyed by issuer alone so they can be reused across many enterprise-managed servers. + const authProviderId = `xaa:${issuer.toString(true)}`; + const { metadata: serverMetadata } = await fetchAuthorizationServerMetadata(issuer.toString(true)); + + // Prefer the user-configured IdP client_id / client_secret over any cached registration. + // XAA requires a pre-provisioned (admin-approved) client_id at the IdP — there is no DCR + // fallback — so an explicit setting is the most reliable source. Typically delivered via + // enterprise policy; developers may hand-edit settings.json for local testing. + const configuredIdp = this.configurationService.getValue(mcpEnterpriseManagedAuthIdpSection) ?? {}; + const configuredClientId = configuredIdp.clientId?.trim() || undefined; + const configuredClientSecret = configuredIdp.clientSecret?.trim() || undefined; + const cached = await this.dynamicAuthProviderStorageService.getClientRegistration(authProviderId); + const clientId = configuredClientId ?? cached?.clientId; + const clientSecret = configuredClientSecret ?? cached?.clientSecret; + let initialTokens: (IAuthorizationTokenResponse & { created_at: number })[] | undefined = undefined; + if (clientId) { + initialTokens = await this.dynamicAuthProviderStorageService.getSessionsForDynamicAuthProvider(authProviderId, clientId); + } + // Note: XAA does NOT use CIMD or DCR — the requesting app must be pre-registered with the + // IdP under an admin-approved cross-app-access trust relationship. The ext-host side + // (`$registerXaaAuthProvider`) prompts the user for client_id + client_secret when there + // is no cached registration and no configured value. + return await this._proxy.$registerXaaAuthProvider( + issuer, + serverMetadata, + clientId, + clientSecret, + initialTokens + ); } })); } @@ -656,4 +693,49 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu clientSecret: clientSecret?.trim() || undefined }; } + + async $promptForResourceClientSecret(resourceClientId: string, resource: string): Promise { + // Surface to the user that whatever they enter (including blank == none) will be remembered + // in OS secret storage, scoped to the MCP server URL + the resource client_id. This means: + // - the codelens above `oauth.clientId` in mcp.json will flip to "Replace Client Secret" + // - subsequent runs read the secret directly from storage and never re-prompt. + // + // Return contract: + // - `undefined` — user pressed Escape (cancelled). Caller should NOT cache; re-prompt allowed. + // - `''` (empty string) — user pressed Enter with blank input ("no secret"). Caller SHOULD + // cache this as an explicit answer (public client / token_endpoint_auth_method=none). + // - `'value'` — user supplied a secret. + const value = await this.quickInputService.input({ + title: nls.localize('xaaResourceSecretTitle', "Resource Client Secret Required"), + prompt: nls.localize( + 'xaaResourceSecretPrompt', + "The resource at '{0}' uses a per-resource client identifier '{1}'. Enter the matching client secret (leave blank if none). The value is saved in OS secret storage; manage it later via the 'Set Client Secret' code lens in mcp.json.", + resource, + resourceClientId, + ), + placeHolder: nls.localize('xaaResourceSecretPlaceholder', "Resource client secret"), + password: true, + ignoreFocusLost: true, + }); + if (value === undefined) { + // User cancelled (Escape). Don't persist anything. + return undefined; + } + const trimmed = value.trim(); + const key = mcpOAuthClientSecretStorageKey(resource, resourceClientId); + try { + if (trimmed.length === 0) { + // Blank-on-confirm means "no client secret" (e.g. token_endpoint_auth_method=none). + // Clear any stale value so subsequent prompts can still capture a fresh secret if needed. + await this.secretStorageService.delete(key); + } else { + await this.secretStorageService.set(key, trimmed); + } + } catch (err) { + this.logService.warn(`[XAA] Failed to persist resource client secret for ${resource} / ${resourceClientId}: ${(err as Error).message}`); + } + // Distinct from cancel: return '' (not undefined) for blank-on-confirm so callers can + // proceed without a client secret instead of treating it as a cancel. + return trimmed; + } } diff --git a/src/vs/workbench/api/browser/mainThreadChatQuota.ts b/src/vs/workbench/api/browser/mainThreadChatQuota.ts new file mode 100644 index 0000000000000..e9a49eccccbaf --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatQuota.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IChatEntitlementService } from '../../services/chat/common/chatEntitlementService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { IQuotaSnapshotsDto, MainContext, MainThreadChatQuotaShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadChatQuota) +export class MainThreadChatQuota extends Disposable implements MainThreadChatQuotaShape { + + constructor( + extHostContext: IExtHostContext, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + ) { + super(); + } + + $updateQuotas(quotas: IQuotaSnapshotsDto): void { + this._chatEntitlementService.acceptQuotas({ ...this._chatEntitlementService.quotas, ...quotas }); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index 2dca9548f7a65..a6f6a54e3c213 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -14,6 +14,7 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as nls from '../../../nls.js'; import { ContextKeyExpr, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { LogLevel } from '../../../platform/log/common/log.js'; @@ -22,6 +23,7 @@ import { ISecretStorageService } from '../../../platform/secrets/common/secrets. import { IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; import { extensionPrefixedIdentifier, McpCollectionDefinition, McpCollectionSortOrder, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, mcpOAuthClientSecretStorageKey, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; +import { IMcpEnterpriseManagedAuthIdpConfig, mcpEnterpriseManagedAuthIdpSection } from '../../contrib/mcp/common/mcpConfiguration.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { IAuthenticationMcpAccessService } from '../../services/authentication/browser/authenticationMcpAccessService.js'; import { IAuthenticationMcpService } from '../../services/authentication/browser/authenticationMcpService.js'; @@ -62,6 +64,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IWorkbenchMcpGatewayService private readonly _mcpGatewayService: IWorkbenchMcpGatewayService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, ) { super(); @@ -237,6 +240,51 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { const authorizationServer = URI.revive(authDetails.authorizationServer); const resourceServer = authDetails.resourceMetadata?.resource ? URI.parse(authDetails.resourceMetadata.resource) : undefined; const resolvedScopes = authDetails.scopes ?? authDetails.resourceMetadata?.scopes_supported ?? authDetails.authorizationServerMetadata.scopes_supported ?? []; + + // Enterprise-managed servers route through an XAA / ID-JAG provider keyed by the user-configured + // SSO issuer instead of doing a per-server DCR against the resource's authorization server. + if (authDetails.enterpriseManaged) { + const resource = authDetails.resourceMetadata?.resource; + if (!resource) { + throw new Error(nls.localize('mcp.enterpriseManaged.missingResource', "The enterprise-managed MCP server '{0}' did not advertise a protected-resource metadata document with a 'resource' identifier.", server.label)); + } + // Per ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant), the token exchange + // `audience` is the *authorization server* of the resource — i.e. the issuer that will + // redeem the ID-JAG assertion. We pick the first server advertised by the resource's + // oauth-protected-resource metadata. + const resourceAuthServers = authDetails.resourceMetadata?.authorization_servers ?? []; + const audience = resourceAuthServers[0]; + if (!audience) { + throw new Error(nls.localize('mcp.enterpriseManaged.missingAS', "The enterprise-managed MCP server '{0}' did not advertise an `authorization_servers` entry in its protected-resource metadata.", server.label)); + } + // For XAA the scopes sent to the IdP token-exchange step are the *resource* scopes + // (e.g. "todos.read mcp.access"), NOT the IdP login scopes (openid/offline_access/…). + // `resolvedScopes` may have fallen through to `authorizationServerMetadata.scopes_supported` + // which is the IdP's metadata — wrong for this step. Use only the scopes derived from the + // WWW-Authenticate challenge or the resource's own metadata. + const xaaScopes = authDetails.scopes ?? authDetails.resourceMetadata?.scopes_supported ?? []; + const issuer = this._ensureXaaIssuer(); + const xaaProviderId = await this._authenticationService.createOrGetXaaProvider(issuer); + if (!xaaProviderId) { + return undefined; + } + const resourceClientId = clientId ?? authDetails.clientId; + // Resolve the resource-AS client secret from secret storage, keyed by the resource indicator + // + the configured resource client_id. Set via the "Set Client Secret" code lens above + // `oauth.clientId` in mcp.json (the server URL equals the resource indicator per RFC 9470). + // Using `resource` (not the server launch URI) ensures the key matches what the prompt + // writes in $promptForResourceClientSecret, so prompted secrets survive window reload. + let resourceClientSecret: string | undefined; + if (resourceClientId) { + try { + resourceClientSecret = await this._secretStorageService.get(mcpOAuthClientSecretStorageKey(resource, resourceClientId)); + } catch { + // Best-effort lookup; fall through. + } + } + return this._getSessionForProvider(id, server, xaaProviderId, xaaScopes, issuer, errorOnUserInteraction, resourceClientId, resource, audience, resourceClientSecret); + } + let providerId = await this._authenticationService.getOrActivateProviderIdForServer(authorizationServer, resourceServer); const resolvedClientId = clientId ?? authDetails.clientId; @@ -282,7 +330,25 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { providerId = provider.id; } - return this._getSessionForProvider(id, server, providerId, resolvedScopes, authorizationServer, errorOnUserInteraction, resolvedClientId, authDetails.resourceMetadata?.resource, clientSecret); + return this._getSessionForProvider(id, server, providerId, resolvedScopes, authorizationServer, errorOnUserInteraction, resolvedClientId, authDetails.resourceMetadata?.resource, /* audience */ undefined, clientSecret); + } + + private _ensureXaaIssuer(): URI { + const config = this._configurationService.getValue(mcpEnterpriseManagedAuthIdpSection) ?? {}; + const configuredIssuer = config.issuer?.trim(); + if (!configuredIssuer) { + throw new Error(nls.localize('mcp.enterpriseManaged.issuerMissing', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to be configured. Set it via enterprise policy (Windows Group Policy / macOS managed preferences / Linux `/etc/vscode/policy.json`) or, for local testing, by hand-editing `settings.json`.")); + } + let parsed: URI; + try { + parsed = URI.parse(configuredIssuer); + } catch { + throw new Error(nls.localize('mcp.enterpriseManaged.issuerInvalid', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to be a valid `https://` URL; got '{0}'.", configuredIssuer)); + } + if (parsed.scheme !== 'https') { + throw new Error(nls.localize('mcp.enterpriseManaged.issuerNotHttps', "Enterprise-managed MCP authentication requires `mcp.enterpriseManagedAuth.idp.issuer` to use the `https` scheme; got '{0}'.", configuredIssuer)); + } + return parsed; } private async _getSessionForProvider( @@ -294,9 +360,10 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { errorOnUserInteraction: boolean = false, clientId?: string, resource?: string, + audience?: string, clientSecret?: string, ): Promise { - const sessions = await this._authenticationService.getSessions(providerId, scopes, { authorizationServer, clientId, clientSecret, resource }, true); + const sessions = await this._authenticationService.getSessions(providerId, scopes, { authorizationServer, clientId, clientSecret, resource, audience }, true); const accountNamePreference = this.authenticationMcpServersService.getAccountPreference(server.id, providerId); let matchingAccountPreferenceSession: AuthenticationSession | undefined; if (accountNamePreference) { @@ -351,7 +418,8 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { authorizationServer, clientId, clientSecret, - resource + resource, + audience }); } while ( accountToCreate diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 7420337083012..a4ce4c5b0e43b 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -41,6 +41,7 @@ import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; import { ExtHostChatOutputRenderer } from './extHostChatOutputRenderer.js'; import { ExtHostChatSessions } from './extHostChatSessions.js'; import { ExtHostChatStatus } from './extHostChatStatus.js'; +import { ExtHostChatQuota } from './extHostChatQuota.js'; import { ExtHostChatInputNotification } from './extHostChatInputNotification.js'; import { ExtHostClipboard } from './extHostClipboard.js'; import { ExtHostEditorInsets } from './extHostCodeInsets.js'; @@ -251,6 +252,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); const extHostBrowsers = rpcProtocol.set(ExtHostContext.ExtHostBrowsers, new ExtHostBrowsers(rpcProtocol)); + const extHostChatQuota = rpcProtocol.set(ExtHostContext.ExtHostChatQuota, new ExtHostChatQuota(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); @@ -1709,6 +1711,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, + updateQuotas: (quotas: vscode.ChatQuotaSnapshots) => { + checkProposedApiEnabled(extension, 'chatParticipantPrivate'); + extHostChatQuota.updateQuotas(quotas); + }, registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); extHostApiDeprecation.report('chat.registerChatSessionItemProvider', extension, `Please migrate to the new chat session controller API`, { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7da3b5b282319..18b9d89ac0611 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -213,6 +213,12 @@ export interface IRegisterDynamicAuthenticationProviderDetails extends IRegister authorizationServer: UriComponents; } +export interface IXaaProviderDiscovery { + issuer: UriComponents; + serverMetadata: IAuthorizationServerMetadata; + clientId?: string; +} + export interface MainThreadAuthenticationShape extends IDisposable { $registerAuthenticationProvider(details: IRegisterAuthenticationProviderDetails): Promise; $unregisterAuthenticationProvider(id: string): Promise; @@ -225,6 +231,7 @@ export interface MainThreadAuthenticationShape extends IDisposable { $showContinueNotification(message: string): Promise; $showDeviceCodeModal(userCode: string, verificationUri: string): Promise; $promptForClientRegistration(authorizationServerUrl: string): Promise<{ clientId: string; clientSecret?: string } | undefined>; + $promptForResourceClientSecret(resourceClientId: string, resource: string): Promise; $registerDynamicAuthenticationProvider(details: IRegisterDynamicAuthenticationProviderDetails): Promise; $setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; $sendDidChangeDynamicProviderInfo({ providerId, clientId, authorizationServer, label, clientSecret }: { providerId: string; clientId?: string; authorizationServer?: UriComponents; label?: string; clientSecret?: string }): Promise; @@ -2487,6 +2494,7 @@ export interface ExtHostAuthenticationShape { $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]): Promise; $onDidUnregisterAuthenticationProvider(id: string): Promise; $registerDynamicAuthProvider(authorizationServer: UriComponents, serverMetadata: IAuthorizationServerMetadata, resource?: IAuthorizationProtectedResourceMetadata, clientId?: string, clientSecret?: string, initialTokens?: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; + $registerXaaAuthProvider(issuer: UriComponents, serverMetadata: IAuthorizationServerMetadata, clientId?: string, clientSecret?: string, initialTokens?: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens?: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; } @@ -2723,6 +2731,36 @@ export interface IChatUsageDto { promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]; } +export interface IQuotaSnapshotDto { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly hasQuota?: boolean; + readonly resetAt?: number; + readonly usageBasedBilling?: boolean; + readonly entitlement?: number; + readonly quotaRemaining?: number; +} + +export interface IRateLimitSnapshotDto { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; +} + +export interface IQuotaSnapshotsDto { + readonly resetDate?: string; + readonly resetDateHasTime?: boolean; + readonly usageBasedBilling?: boolean; + readonly canUpgradePlan?: boolean; + readonly chat?: IQuotaSnapshotDto; + readonly completions?: IQuotaSnapshotDto; + readonly premiumChat?: IQuotaSnapshotDto; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly sessionRateLimit?: IRateLimitSnapshotDto; + readonly weeklyRateLimit?: IRateLimitSnapshotDto; +} + export type ICellEditOperationDto = notebookCommon.ICellMetadataEdit | notebookCommon.IDocumentMetadataEdit @@ -3594,6 +3632,13 @@ export interface IMcpAuthenticationDetails { resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined; scopes: string[] | undefined; clientId?: string; + /** + * When true, the MCP server has opted into enterprise-managed authentication + * (OAuth Identity Assertion Authorization Grant). The main thread is expected + * to route token acquisition through the XAA authentication provider for the + * configured issuer rather than the per-resource dynamic auth provider. + */ + enterpriseManaged?: boolean; } export interface IMcpAuthenticationOptions { @@ -3727,6 +3772,13 @@ export interface MainThreadChatStatusShape { $disposeEntry(id: string): void; } +export interface MainThreadChatQuotaShape extends IDisposable { + $updateQuotas(quotas: IQuotaSnapshotsDto): void; +} + +export interface ExtHostChatQuotaShape { +} + export const enum ChatInputNotificationSeverityDto { Info = 0, Warning = 1, @@ -3997,6 +4049,7 @@ export const MainContext = { MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), + MainThreadChatQuota: createProxyIdentifier('MainThreadChatQuota'), MainThreadChatInputNotification: createProxyIdentifier('MainThreadChatInputNotification'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), @@ -4084,6 +4137,7 @@ export const ExtHostContext = { ExtHostMcp: createProxyIdentifier('ExtHostMcp'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), + ExtHostChatQuota: createProxyIdentifier('ExtHostChatQuota'), ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), ExtHostBrowsers: createProxyIdentifier('ExtHostBrowsers'), }; diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 5bd4fbf896920..847780f62c419 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -27,6 +27,7 @@ import { IExtHostProgress } from './extHostProgress.js'; import { IProgressStep } from '../../../platform/progress/common/progress.js'; import { CancellationError, isCancellationError } from '../../../base/common/errors.js'; import { raceCancellationError, SequencerByKey } from '../../../base/common/async.js'; +import { XaaifyAuthProvider } from './extHostXaaAuthProvider.js'; export interface IExtHostAuthentication extends ExtHostAuthentication { } export const IExtHostAuthentication = createDecorator('IExtHostAuthentication'); @@ -43,6 +44,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { declare _serviceBrand: undefined; protected readonly _dynamicAuthProviderCtor = DynamicAuthProvider; + protected readonly _xaaAuthProviderCtor = XaaifyAuthProvider(DynamicAuthProvider); private _proxy: MainThreadAuthenticationShape; private _authenticationProviders: Map = new Map(); @@ -338,6 +340,75 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { + return provider.id; + } + + async $registerXaaAuthProvider( + issuerComponents: UriComponents, + serverMetadata: IAuthorizationServerMetadata, + clientId: string | undefined, + clientSecret: string | undefined, + initialTokens: IAuthorizationToken[] | undefined + ): Promise { + const issuer = URI.revive(issuerComponents); + // XAA does not use Dynamic Client Registration — the IdP must already trust the requesting + // app for the target audience(s). Always require an admin-provisioned client_id (and + // typically client_secret). + if (!clientId) { + this._logService.info(`Prompting user for client registration details for XAA issuer ${issuer.toString()}`); + const clientDetails = await this._proxy.$promptForClientRegistration(issuer.toString()); + if (!clientDetails) { + throw new Error('User did not provide client details'); + } + clientId = clientDetails.clientId; + clientSecret = clientDetails.clientSecret; + } + const provider = new this._xaaAuthProviderCtor( + this._extHostWindow, + this._extHostUrls, + this._initData, + this._extHostProgress, + this._extHostLoggerService, + this._proxy, + issuer, + serverMetadata, + /* resourceMetadata */ undefined, + clientId, + clientSecret, + this._onDidDynamicAuthProviderTokensChange, + initialTokens || [] + ); + + await this._providerOperations.queue(provider.id, async () => { + this._authenticationProviders.set( + provider.id, + { + label: provider.label, + provider, + disposable: Disposable.from( + provider, + provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(provider.id, e)), + provider.onDidChangeClientId(() => this._proxy.$sendDidChangeDynamicProviderInfo({ + providerId: provider.id, + clientId: provider.clientId, + clientSecret: provider.clientSecret + })) + ), + options: { supportsMultipleAccounts: true } + } + ); + + await this._proxy.$registerDynamicAuthenticationProvider({ + id: provider.id, + label: provider.label, + supportsMultipleAccounts: true, + authorizationServer: issuerComponents, + resourceServer: undefined, + clientId: provider.clientId, + clientSecret: provider.clientSecret + }); + }); + return provider.id; } @@ -362,7 +433,7 @@ class TaskSingler { } export class DynamicAuthProvider implements vscode.AuthenticationProvider { - readonly id: string; + id: string; readonly label: string; private _onDidChangeSessions = new Emitter(); @@ -808,14 +879,14 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { } } -type IAuthorizationToken = IAuthorizationTokenResponse & { +export type IAuthorizationToken = IAuthorizationTokenResponse & { /** * The time when the token was created, in milliseconds since the epoch. */ created_at: number; }; -class TokenStore implements Disposable { +export class TokenStore implements Disposable { private readonly _tokensObservable: ISettableObservable; private readonly _sessionsObservable: IObservable; diff --git a/src/vs/workbench/api/common/extHostChatQuota.ts b/src/vs/workbench/api/common/extHostChatQuota.ts new file mode 100644 index 0000000000000..38e037ada815c --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatQuota.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ExtHostChatQuotaShape, IMainContext, IQuotaSnapshotsDto, MainContext, MainThreadChatQuotaShape } from './extHost.protocol.js'; + +export class ExtHostChatQuota extends Disposable implements ExtHostChatQuotaShape { + + private readonly _proxy: MainThreadChatQuotaShape; + + constructor( + mainContext: IMainContext, + ) { + super(); + this._proxy = mainContext.getProxy(MainContext.MainThreadChatQuota); + } + + updateQuotas(quotas: IQuotaSnapshotsDto): void { + this._proxy.$updateQuotas(quotas); + } +} diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index fddb22fd1577c..52e0ec06853d7 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -707,6 +707,7 @@ export class McpHTTPHandle extends Disposable { resourceMetadata: this._authMetadata.resourceMetadata, scopes: this._authMetadata.scopes, clientId: this._launch.oauth?.clientId, + enterpriseManaged: this._launch.oauth?.enterpriseManaged, }; const token = await this._proxy.$getTokenFromServerMetadata( this._id, diff --git a/src/vs/workbench/api/common/extHostXaaAuthProvider.ts b/src/vs/workbench/api/common/extHostXaaAuthProvider.ts new file mode 100644 index 0000000000000..012cc8d005bca --- /dev/null +++ b/src/vs/workbench/api/common/extHostXaaAuthProvider.ts @@ -0,0 +1,393 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { stringHash } from '../../../base/common/hash.js'; +import { buildIdJagExchangeBody, buildResourceRedemptionBody, fetchAuthorizationServerMetadata, getClaimsFromJWT, IAuthorizationJWTClaims, IAuthorizationTokenResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js'; +import { DynamicAuthProvider } from './extHostAuthentication.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Ctor = new (...args: any[]) => T; + +/** + * Scopes used when bootstrapping the IdP session for an XAA flow. + * + * `openid` is required because the ID-JAG token exchange uses the IdP-issued + * `id_token` as `subject_token` (per draft-ietf-oauth-identity-assertion-authz-grant + * section 3.1, the subject token MUST be of type `urn:ietf:params:oauth:token-type:id_token`). + * `offline_access` is requested so we get a refresh token for the IdP session. + */ +export const IDP_SCOPES: readonly string[] = ['openid', 'offline_access']; + +interface IResourceCacheEntry { + readonly resource: string; + readonly scopes: readonly string[]; + readonly token: IAuthorizationTokenResponse; + readonly created_at: number; +} + +/** Cache key for resource-scoped tokens. Exported for testing. */ +export function cacheKey(resource: string, scopes: readonly string[]): string { + return resource + '|' + [...scopes].sort().join(' '); +} + +/** + * Returns true if the cached token is past (or within 60s of) its expiry. Pure + * and exported for testing. + * + * Mints fresh ID-JAG assertions are usually short-lived (minutes). We treat tokens as expired + * 60s before their nominal expiry to avoid clock skew and in-flight redemptions racing past + * `exp`. Tokens without `expires_in` defined are treated as never-expiring (cached + * until the process exits); `expires_in: 0` is treated as immediately expired. + */ +export function isExpired(entry: { token: { expires_in?: number }; created_at: number }, now: number = Date.now()): boolean { + if (entry.token.expires_in === undefined) { + return false; + } + return now > entry.created_at + (entry.token.expires_in * 1000) - 60_000; +} + +/** + * (Preview) Mixin that turns a {@link DynamicAuthProvider} subclass into a + * Cross App Access (XAA) / enterprise-managed authentication provider, per + * `draft-ietf-oauth-identity-assertion-authz-grant`. + * + * The IdP login leg is identical to the base class — Auth Code + PKCE against + * the org-configured issuer, using the pre-registered client credentials. On + * top of that: + * + * 1. `createSession` ensures an IdP session exists (delegated to the base + * class with {@link IDP_SCOPES}). + * 2. It POSTs to the IdP token endpoint with `grant_type=token-exchange`, + * `subject_token=`, `subject_token_type=id_token`, + * `requested_token_type=id-jag`, `audience=`, + * `resource=`, `scope=` to mint an + * ID-JAG. + * 3. It discovers the resource's authorization server metadata (the audience + * URL) and POSTs the ID-JAG to its token endpoint with + * `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, + * `assertion=`, `resource=`, + * `scope=` to obtain a resource-scoped access token. + * 4. The resource-scoped token is cached in-memory per `(resource, scopes)` + * and returned as the session's access token. + * + * The resource indicator is read from `options.resource` (RFC 8707) and the + * resource's authorization server URL from `options.audience` on + * {@link vscode.AuthenticationProviderSessionOptions}. + */ +export function XaaifyAuthProvider>(Base: TBase): TBase { + return class XaaAuthenticationProvider extends Base { + private readonly _resourceTokens = new Map(); + /** + * Per-(resource, client_id) client secrets. Lazily populated via the main-thread + * prompt. Keyed by both the resource indicator and the client_id because two + * different resources may legitimately share a client_id but require different + * secrets — keying by client_id alone could send the wrong secret to the wrong AS. + */ + private readonly _resourceClientSecrets = new Map(); + + /** Compound key for {@link _resourceClientSecrets}, matching main-thread secret storage scoping. */ + private _resourceClientSecretKey(resource: string, clientId: string): string { + return `${resource}|${clientId}`; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + super(...args); + // `authorizationServer` is exposed as a readonly field by the base class — use it + // directly instead of indexing into `args` so this can't silently break if the + // base constructor signature changes. + const issuer = this.authorizationServer; + this.id = `xaa:${issuer.toString(true)}`; + this._logger.trace(`[XAA] Provider constructed for issuer ${issuer.toString(true)}. authorization_endpoint=${this._serverMetadata.authorization_endpoint}, token_endpoint=${this._serverMetadata.token_endpoint}`); + } + + override async getSessions(scopes: readonly string[] | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise { + const resource = options.resource; + const audience = options.audience; + if (!resource || !scopes || !audience) { + return []; + } + // 1. Fast path: in-memory cache from a prior createSession/getSessions in this window. + const key = cacheKey(resource, scopes); + const entry = this._resourceTokens.get(key); + if (entry && !isExpired(entry)) { + return [toSession(entry.token, entry.scopes)]; + } + if (entry) { + // Expired — drop and try to silently re-mint below. + this._resourceTokens.delete(key); + } + + // 2. Silent re-mint: the base DynamicAuthProvider persists the IdP session in secret + // storage, so on window reload we can pick it up and re-run legs 2-4 (ID-JAG exchange + // + resource redemption) without any user interaction. Per the IAuthenticationProvider + // contract, getSessions MUST NOT prompt — if anything is missing we just return []. + const idpSession = await this._tryGetSilentIdpSession(); + if (!idpSession?.idToken) { + return []; + } + try { + const minted = await this._mintResourceToken(idpSession, [...scopes], audience, resource, options, /* silent */ true); + if (!minted) { + return []; + } + return [toSession(minted.token, minted.scopes)]; + } catch (err) { + // Silent path: log and fall back to "no session" so the caller decides whether + // to escalate to createSession (which is allowed to interact). + this._logger.warn(`[XAA] Silent token mint failed for resource=${resource}; falling back to interactive. Error: ${(err as Error).message}`); + return []; + } + } + + override async createSession(scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise { + const audience = options.audience; + const resource = options.resource; + this._logger.trace(`[XAA] createSession scopes=[${scopes.join(' ')}] audience=${audience} resource=${resource}`); + if (!audience) { + throw new Error('Enterprise-managed authentication requires `options.audience` (the resource\'s authorization server URL) but none was provided.'); + } + if (!resource) { + throw new Error('Enterprise-managed authentication requires `options.resource` (the resource indicator / MCP server URL) but none was provided.'); + } + + // Ensure IdP session via the base class (may interact). Don't pass the XAA options through — + // the IdP login leg is unrelated to the resource/audience, and the base provider would + // otherwise look for cached tokens scoped by a foreign audience. + const idpSession = await this._ensureIdpSession(); + if (!idpSession.idToken) { + throw new Error('IdP session is missing an id_token; the issuer must support OpenID Connect and the `openid` scope.'); + } + + const minted = await this._mintResourceToken(idpSession, scopes, audience, resource, options, /* silent */ false); + if (!minted) { + // `silent=false` only returns undefined if the mint logic itself decided to bail. + // Today the only such path is missing resource client_secret, which prompts the user; + // if the prompt is dismissed we still try the redemption with `undefined` (valid for + // `token_endpoint_auth_method=none`). So in practice this branch is unreachable for + // silent=false — guard defensively anyway. + throw new Error('Failed to mint a resource access token for the enterprise-managed MCP server.'); + } + return toSession(minted.token, minted.scopes); + } + + /** + * Mints a resource-scoped access token by running legs 2-4 of the XAA flow: + * 2. Exchange IdP id_token → ID-JAG (RFC 8693 token exchange at issuer) + * 3. Discover the resource AS token endpoint + * 4. Redeem the ID-JAG at the resource AS for an access token (RFC 7523 jwt-bearer grant) + * + * When `silent` is true, this method MUST NOT prompt the user. If the resource AS uses a + * distinct client_id (xaa.dev's "{client}-at-{resource}" pattern) and no client_secret can + * be resolved without prompting, this returns `undefined`. + * + * Caches the resulting token in `_resourceTokens` so subsequent getSessions are O(1). + */ + private async _mintResourceToken( + idpSession: vscode.AuthenticationSession, + scopes: string[], + audience: string, + resource: string, + options: vscode.AuthenticationProviderSessionOptions, + silent: boolean, + ): Promise { + // Leg 2: id_token → ID-JAG + const jag = await this._exchangeForIdJag(idpSession.idToken!, audience, resource, scopes); + + // Leg 3: resource AS token endpoint + const resourceTokenEndpoint = await this._discoverResourceTokenEndpoint(audience); + + // Leg 4 prep: resolve the resource client_id. + // Per draft-ietf-oauth-identity-assertion-authz-grant section 3.2, the ID-JAG carries a + // `client_id` claim identifying the requesting app to the resource AS. This is often + // distinct from the IdP `client_id` (xaa.dev for example uses a + // `{idp_client_id}-at-{resource}` form), so we extract it from the assertion rather than + // reusing `this._clientId`. Caller-supplied `options.clientId` (from the MCP server's + // `oauth.clientId` config) takes precedence over the JAG-extracted value. + let resourceClientId = this._clientId; + let resourceClientIdFromJag = false; + const configuredResourceClientId = typeof options.clientId === 'string' && options.clientId.length > 0 ? options.clientId : undefined; + if (configuredResourceClientId) { + resourceClientId = configuredResourceClientId; + resourceClientIdFromJag = resourceClientId !== this._clientId; + } else { + try { + const jagClaims = getClaimsFromJWT(jag); + if (typeof jagClaims.client_id === 'string' && jagClaims.client_id.length > 0) { + resourceClientId = jagClaims.client_id; + resourceClientIdFromJag = resourceClientId !== this._clientId; + } + } catch (err) { + this._logger.warn(`[XAA] Could not decode ID-JAG to read resource client_id; falling back to IdP client_id. Error: ${(err as Error).message}`); + } + } + + // Leg 4 prep: resolve the resource client_secret. + // If the resource AS uses a distinct client_id, it will reject `this._clientSecret` + // (the IdP secret) with `invalid_client`. The caller may supply the resource secret + // directly via `options.clientSecret` (resolved in `mainThreadMcp` from URL-scoped + // secret storage via the "Set Client Secret" code lens above `oauth.clientId` in + // mcp.json); otherwise we fall back to a cached per-resource secret or prompt the + // user. We pass `undefined` if the user leaves the prompt blank — that's valid for + // clients registered with `token_endpoint_auth_method=none`. + let resourceClientSecret: string | undefined = this._clientSecret; + const configuredResourceClientSecret = typeof options.clientSecret === 'string' && options.clientSecret.length > 0 ? options.clientSecret : undefined; + const secretCacheKey = this._resourceClientSecretKey(resource, resourceClientId); + if (configuredResourceClientSecret) { + resourceClientSecret = configuredResourceClientSecret; + this._resourceClientSecrets.set(secretCacheKey, configuredResourceClientSecret); + } else if (resourceClientIdFromJag) { + if (this._resourceClientSecrets.has(secretCacheKey)) { + resourceClientSecret = this._resourceClientSecrets.get(secretCacheKey); + } else if (silent) { + // Silent path: the only way to obtain the resource client_secret here is to + // prompt the user — which we can't do. Bail; the caller will escalate to + // createSession (allowed to interact) if it needs the token. + this._logger.info(`[XAA] Silent mint requires resource client_secret for '${resourceClientId}' but none is cached or configured; deferring to interactive flow.`); + return undefined; + } else { + this._logger.info(`[XAA] Resource AS requires a distinct client_id '${resourceClientId}' — prompting for matching client_secret.`); + const promptedSecret = await this._proxy.$promptForResourceClientSecret(resourceClientId, resource); + if (promptedSecret === undefined) { + // User cancelled — don't cache, so re-prompt is possible on next call. + return undefined; + } + // Blank-on-confirm is a valid answer (public client / token_endpoint_auth_method=none). + // The main thread returns '' for that case, undefined for cancel. + this._resourceClientSecrets.set(secretCacheKey, promptedSecret); + resourceClientSecret = promptedSecret.length > 0 ? promptedSecret : undefined; + } + } + + // Leg 4: redemption. + const resourceToken = await this._redeemAtResource(resourceTokenEndpoint, jag, resource, scopes, resourceClientId, resourceClientSecret); + + const entry: IResourceCacheEntry = { + resource, + scopes, + token: resourceToken, + created_at: Date.now(), + }; + this._resourceTokens.set(cacheKey(resource, scopes), entry); + return entry; + } + + /** + * Returns the IdP session if one is available without any user interaction, otherwise + * `undefined`. Critically does NOT call `super.createSession`, so this is safe to use + * from {@link getSessions}. + */ + private async _tryGetSilentIdpSession(): Promise { + const cleanOptions: vscode.AuthenticationProviderSessionOptions = {}; + const existing = await super.getSessions(IDP_SCOPES as string[], cleanOptions); + return existing.length ? existing[0] : undefined; + } + + private async _ensureIdpSession(): Promise { + this._logger.trace(`[XAA] _ensureIdpSession: scopes=[${IDP_SCOPES.join(' ')}] authorization_endpoint=${this._serverMetadata.authorization_endpoint}`); + const silent = await this._tryGetSilentIdpSession(); + if (silent?.idToken) { + this._logger.trace(`[XAA] _ensureIdpSession: reusing existing IdP session`); + return silent; + } + this._logger.trace(`[XAA] _ensureIdpSession: creating new IdP session via super.createSession`); + return super.createSession([...IDP_SCOPES], {}); + } + + private async _exchangeForIdJag(idToken: string, audience: string, resource: string, scopes: string[]): Promise { + const tokenEndpoint = this._serverMetadata.token_endpoint; + if (!tokenEndpoint) { + throw new Error('Issuer metadata is missing token_endpoint; cannot perform XAA token exchange.'); + } + const body = buildIdJagExchangeBody(this._clientId, this._clientSecret, idToken, audience, resource, scopes); + this._logger.trace(`[XAA] POST ${tokenEndpoint} (ID-JAG exchange) audience=${audience} resource=${resource} scope=${scopes.join(' ')}`); + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + throw new Error(`XAA token exchange (IdP) failed: ${response.status} ${await safeText(response)}`); + } + const data: unknown = await response.json(); + const issued = (data && typeof data === 'object' && typeof (data as { access_token?: unknown }).access_token === 'string') + ? (data as { access_token: string }).access_token + : undefined; + if (!issued) { + throw new Error(`XAA token exchange (IdP) returned no access_token. Response: ${JSON.stringify(data)}`); + } + return issued; + } + + private async _discoverResourceTokenEndpoint(audience: string): Promise { + const { metadata, errors } = await fetchAuthorizationServerMetadata(audience); + if (!metadata?.token_endpoint) { + throw new Error(`Failed to discover resource authorization server metadata for '${audience}': ${errors.map(e => e.message).join('; ') || 'no token_endpoint in metadata'}`); + } + return metadata.token_endpoint; + } + + private async _redeemAtResource(tokenEndpoint: string, idJag: string, resource: string, scopes: string[], resourceClientId: string, resourceClientSecret: string | undefined): Promise { + const body = buildResourceRedemptionBody(resourceClientId, resourceClientSecret, idJag, resource, scopes); + this._logger.trace(`[XAA] POST ${tokenEndpoint} (ID-JAG redemption) client_id=${resourceClientId} resource=${resource} scope=${scopes.join(' ')}`); + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + throw new Error(`XAA token exchange (resource) failed: ${response.status} ${await safeText(response)}`); + } + const data = await response.json(); + if (!isAuthorizationTokenResponse(data)) { + throw new Error(`XAA token exchange (resource) returned an invalid token response: ${JSON.stringify(data)}`); + } + return data; + } + }; +} + +function toSession(token: IAuthorizationTokenResponse, scopes: readonly string[]): vscode.AuthenticationSession { + let claims: IAuthorizationJWTClaims | undefined; + if (token.id_token) { + try { + claims = getClaimsFromJWT(token.id_token); + } catch { + // ignore + } + } + if (!claims) { + try { + claims = getClaimsFromJWT(token.access_token); + } catch { + // ignore + } + } + return { + id: stringHash(token.access_token, 0).toString(), + accessToken: token.access_token, + account: { + id: claims?.sub || 'unknown', + label: claims?.preferred_username || claims?.name || claims?.email || 'XAA', + }, + scopes: [...scopes], + idToken: token.id_token, + }; +} + +async function safeText(response: Response): Promise { + try { + return await response.text(); + } catch { + return response.statusText; + } +} diff --git a/src/vs/workbench/api/node/extHostAuthentication.ts b/src/vs/workbench/api/node/extHostAuthentication.ts index ba12f042dfc46..828a91c336feb 100644 --- a/src/vs/workbench/api/node/extHostAuthentication.ts +++ b/src/vs/workbench/api/node/extHostAuthentication.ts @@ -7,6 +7,7 @@ import * as nls from '../../../nls.js'; import type * as vscode from 'vscode'; import { URL } from 'url'; import { ExtHostAuthentication, DynamicAuthProvider, IExtHostAuthentication } from '../common/extHostAuthentication.js'; +import { XaaifyAuthProvider } from '../common/extHostXaaAuthProvider.js'; import { IExtHostRpcService } from '../common/extHostRpcService.js'; import { IExtHostInitDataService } from '../common/extHostInitDataService.js'; import { IExtHostWindow } from '../common/extHostWindow.js'; @@ -321,6 +322,7 @@ export class NodeDynamicAuthProvider extends DynamicAuthProvider { export class NodeExtHostAuthentication extends ExtHostAuthentication implements IExtHostAuthentication { protected override readonly _dynamicAuthProviderCtor = NodeDynamicAuthProvider; + protected override readonly _xaaAuthProviderCtor = XaaifyAuthProvider(NodeDynamicAuthProvider); constructor( extHostRpc: IExtHostRpcService, diff --git a/src/vs/workbench/api/test/browser/extHostXaaAuthProvider.test.ts b/src/vs/workbench/api/test/browser/extHostXaaAuthProvider.test.ts new file mode 100644 index 0000000000000..f1c1040e5fa17 --- /dev/null +++ b/src/vs/workbench/api/test/browser/extHostXaaAuthProvider.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { + cacheKey, + IDP_SCOPES, + isExpired, +} from '../../common/extHostXaaAuthProvider.js'; + +suite('XaaAuthProvider helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('cacheKey is scope-order-independent', () => { + assert.strictEqual( + cacheKey('https://r.example.com', ['b', 'a']), + cacheKey('https://r.example.com', ['a', 'b']) + ); + }); + + test('cacheKey distinguishes different audiences', () => { + assert.notStrictEqual( + cacheKey('https://r1.example.com', ['s']), + cacheKey('https://r2.example.com', ['s']) + ); + }); + + test('isExpired treats tokens without expires_in as never expiring', () => { + assert.strictEqual( + isExpired({ token: {}, created_at: 0 }, Number.MAX_SAFE_INTEGER), + false + ); + }); + + test('isExpired flags tokens within 60s of expiry as expired', () => { + const created_at = 1_000_000; + const expires_in = 3600; + // 60s before nominal expiry → already expired due to safety margin + const justInsideMargin = created_at + (expires_in * 1000) - 30_000; + assert.strictEqual(isExpired({ token: { expires_in }, created_at }, justInsideMargin), true); + // well before expiry + const earlier = created_at + 1000; + assert.strictEqual(isExpired({ token: { expires_in }, created_at }, earlier), false); + }); + + test('isExpired treats expires_in: 0 as immediately expired', () => { + // Distinguish from `expires_in === undefined` (which means "never"); zero must mean + // "already expired" so a malformed/edge-case AS response can't be served from cache. + assert.strictEqual( + isExpired({ token: { expires_in: 0 }, created_at: 1_000_000 }, 1_000_000), + true + ); + }); + + test('IDP_SCOPES requests an OpenID session with refresh', () => { + assert.deepStrictEqual([...IDP_SCOPES].sort(), ['offline_access', 'openid']); + }); +}); diff --git a/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts b/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts index 150f849dbfff6..54e1eb5915eae 100644 --- a/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts +++ b/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts @@ -121,6 +121,7 @@ suite('MainThreadAuthentication', () => { $removeSession: () => Promise.resolve(), $onDidChangeAuthenticationSessions: () => Promise.resolve(), $registerDynamicAuthProvider: () => Promise.resolve('test'), + $registerXaaAuthProvider: () => Promise.resolve('test'), $onDidChangeDynamicAuthProviderTokens: () => Promise.resolve(), $getSessionsFromChallenges: () => Promise.resolve([]), // eslint-disable-next-line local/code-no-any-casts diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 5e692f9c47d9a..9574fa766b888 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -5,7 +5,7 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; -import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, Dimension, EventType, IDomPosition } from '../../../../base/browser/dom.js'; import { ButtonBar } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -21,22 +21,17 @@ import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { isMacintosh, isLinux } from '../../../../base/common/platform.js'; -import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from './overlayManager.js'; import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; @@ -57,7 +52,7 @@ const originalHtmlElementFocus = HTMLElement.prototype.focus; /** * Base class for browser editor services that track the model lifecycle. * - * Subclasses implement {@link subscribeToModel} which is called whenever a new model is set. + * Subclasses implement {@link onModelAttached} which is called whenever a new model is set. * A {@link DisposableStore} is provided that is automatically cleared when the model * changes or the editor input is cleared. */ @@ -69,9 +64,9 @@ export abstract class BrowserEditorContribution extends Disposable { this._register(editor.onDidChangeModel(({ model, isNew }) => { this._modelStore.clear(); if (model) { - this.subscribeToModel(model, this._modelStore, isNew); + this.onModelAttached(model, this._modelStore, isNew); } else { - this.clear(); + this.onModelDetached(); } })); } @@ -79,12 +74,12 @@ export abstract class BrowserEditorContribution extends Disposable { /** * Called whenever the editor model changes to update state. */ - protected subscribeToModel(_model: IBrowserViewModel, _store: DisposableStore, _isNew: boolean): void { } + protected onModelAttached(_model: IBrowserViewModel, _store: DisposableStore, _isNew: boolean): void { } /** * Called when the model is cleared to reset state. */ - clear(): void { } + onModelDetached(): void { } /** * Optional widgets to display inside the URL bar (on the right side of the URL input, @@ -102,43 +97,110 @@ export abstract class BrowserEditorContribution extends Disposable { /** * Called when the editor is laid out with a new dimension. */ - layout(_width: number): void { } + onPaneResized(_width: number): void { } + + /** + * Called after the browser container has been laid out and its bounds + * pushed to the model. Contributions can use this to react to position + * changes (e.g. recompute overlay overlap), unlike {@link onPaneResized} which + * only fires on pane dimension changes. + */ + afterContainerLayout(): void { } + + /** + * Called when the editor pane's visibility changes (e.g. tab switched). + * Contributions that drive page rendering use this to pause/resume work. + */ + onPaneVisibilityChanged(_visible: boolean): void { } + + /** + * Called when the editor wants focus to land on the page content. Most + * contributions ignore this; the renderer-providing contribution typically + * forwards focus to the underlying page. + */ + focusPage(): void { } /** * Called once after the editor's browser container DOM has been created. * Use to do setup that needs to attach to `editor.browserContainer`. */ - onContainerReady(_container: HTMLElement): void { } + onContainerCreated(_container: HTMLElement): void { } + + /** + * Optional contributions to how the browser container is sized and + * positioned within the editor's wrapper. Multiple contributions are + * supported: padding is taken as the max across all contributors (so each + * contributor's reservation is honoured without double-counting); + * `compute` callbacks are chained in priority order (lower {@link + * IContainerLayoutOverride.priority} runs first), each receiving the + * previous result so contributions can stack (e.g. device emulation sizes + * and centers the viewport, then pixel-snap aligns it). + */ + beforeContainerLayout(): IContainerLayoutOverride | undefined { return undefined; } /** - * Return an override to customize how the editor sizes the browser - * container. Returning `undefined` falls through to the next contribution - * (and finally to the default: container fills the wrapper's content area). - * The first contribution to return a non-undefined override wins. + * Content elements to mount inside the browser container's placeholder + * area (welcome screen, error page, overlay-pause message, etc.). The + * editor stacks them in {@link IBrowserContainerContent.order} order; + * each content manages its own visibility. */ - getContainerLayoutOverride(): IContainerLayoutOverride | undefined { return undefined; } + get containerContents(): readonly IBrowserContainerContent[] { return []; } } -/** Customization returned by {@link BrowserEditorContribution.getContainerLayoutOverride}. */ +/** Customization returned by {@link BrowserEditorContribution.beforeContainerLayout}. */ export interface IContainerLayoutOverride { /** - * Wrapper padding (CSS px) — typically used to reserve space for widgets - * that sit outside the container (e.g. resize sashes). Applied as inline - * style before the pane is measured for {@link compute}. + * Wrapper padding (CSS px) reserved by this contribution — e.g. for + * widgets that sit outside the container (resize sashes), or a baseline + * visual margin. The editor takes the per-side max across all + * contributors and subtracts the result from the wrapper before passing + * the pane info to {@link compute}. Default 0 per side. */ - readonly padding: { + readonly padding?: { top?: number; right?: number; bottom?: number; left?: number; }; - /** Compute the container layout given the measured pane size. */ - compute(paneWidth: number, paneHeight: number): IContainerLayout; + /** + * Transform the layout. Called in priority order (lower runs first); each + * call receives the result of the previous compute plus pane info + * (available size and the absolute screen origin of layout-space (0,0)). + * The initial input is `{ width: pane.width, height: pane.height, top: 0, + * left: 0 }` with no emulation — `top`/`left` are local coordinates + * relative to the top-left of the available area. The pane origin lets + * contributions reason about absolute pixel alignment (e.g. snap to + * physical pixels) and convert back to local coords. Returning + * `undefined` leaves the current layout unchanged. + */ + readonly compute?: (current: IContainerLayout, pane: IContainerLayoutPane) => IContainerLayout | undefined; + /** + * Priority for {@link compute}. Lower numbers run earlier so later + * contributions can refine the result (e.g. emulation runs at priority 0 + * to size/position the viewport; pixel-snap runs at priority 1000 to + * align). Default 0. + */ + readonly priority?: number; +} + +/** Pane info passed to {@link IContainerLayoutOverride.compute}. */ +export interface IContainerLayoutPane { + /** Available width after aggregated padding is applied (CSS px). */ + readonly width: number; + /** Available height after aggregated padding is applied (CSS px). */ + readonly height: number; + /** Absolute screen x of layout-space (0, 0). */ + readonly originX: number; + /** Absolute screen y of layout-space (0, 0). */ + readonly originY: number; } export interface IContainerLayout { readonly width: number; readonly height: number; + /** Local position within the wrapper (CSS px). Defaults to 0. */ + readonly top?: number; + readonly left?: number; readonly emulation?: { readonly scale: number; }; @@ -151,6 +213,17 @@ export interface IBrowserEditorWidgetContribution { readonly order: number; } +/** + * Content that sits inside the browser container's placeholder area (welcome + * screen, error page, overlay-pause message, etc.). Each content owns its own + * visibility — the editor only stacks elements by {@link order}. + */ +export interface IBrowserContainerContent { + readonly element: HTMLElement; + /** Stacking order — lower numbers are farther back (rendered first). */ + readonly order: number; +} + class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; private readonly _urlDisplay: HTMLElement; @@ -383,15 +456,10 @@ export class BrowserEditor extends EditorPane { // -- State ---------------------------------------------------------- - private _overlayVisible = false; - private _editorVisible = false; - private _navigationBar!: BrowserNavigationBar; private _browserContainerWrapper!: HTMLElement; private _browserContainer!: HTMLElement; get browserContainer(): HTMLElement { return this._browserContainer; } - private _placeholderScreenshot!: HTMLElement; - private _overlayPauseContainer!: HTMLElement; private _errorContainer!: HTMLElement; private _welcomeContainer!: HTMLElement; private _canGoBackContext!: IContextKey; @@ -400,18 +468,14 @@ export class BrowserEditor extends EditorPane { private _hasErrorContext!: IContextKey; private readonly _inputDisposables = this._register(new DisposableStore()); - private overlayManager: BrowserOverlayManager | undefined; - private _screenshotTimeout: ReturnType | undefined; private readonly _certActionButton = this._register(new MutableDisposable()); - private _currentPadding: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 3, bottom: 3, left: 3 }; + private _currentPadding: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 0, bottom: 0, left: 0 }; constructor( group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, - @IKeybindingService private readonly keybindingService: IKeybindingService, - @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ILayoutService private readonly layoutService: ILayoutService, @@ -423,9 +487,6 @@ export class BrowserEditor extends EditorPane { // Create scoped context key service for this editor instance const contextKeyService = this._register(this.contextKeyService.createScoped(parent)); - // Create window-specific overlay manager for this editor - this.overlayManager = this._register(new BrowserOverlayManager(this.window)); - // Bind navigation capability context keys this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); @@ -483,31 +544,26 @@ export class BrowserEditor extends EditorPane { this._browserContainer.tabIndex = 0; // make focusable this._browserContainerWrapper.appendChild(this._browserContainer); - // Notify contributions that the container DOM is ready (used e.g. by - // the device feature to attach resize sashes to the container). + // Notify contributions that the container DOM is ready. for (const contribution of this._contributionInstances.values()) { - contribution.onContainerReady(this._browserContainer); + contribution.onContainerCreated(this._browserContainer); } - // Create additional wrapper around placeholder contents for applying border radius clipping. + // Wrapper around placeholder contents for border radius clipping. Holds + // contribution-provided content (placeholder screenshot, overlay-pause) + // plus the editor-owned error and welcome layers. const placeholderContents = $('.browser-placeholder-contents'); this._browserContainer.appendChild(placeholderContents); - // Create placeholder screenshot (background placeholder when WebContentsView is hidden) - this._placeholderScreenshot = $('.browser-placeholder-screenshot'); - placeholderContents.appendChild(this._placeholderScreenshot); - - // Create overlay pause container (hidden by default via CSS) - this._overlayPauseContainer = $('.browser-overlay-paused'); - const overlayPauseMessage = $('.browser-overlay-paused-message'); - const overlayPauseHeading = $('.browser-overlay-paused-heading'); - const overlayPauseDetail = $('.browser-overlay-paused-detail'); - overlayPauseHeading.textContent = localize('browser.overlayPauseHeading.notification', "Paused due to Notification"); - overlayPauseDetail.textContent = localize('browser.overlayPauseDetail.notification', "Dismiss the notification to continue using the browser."); - overlayPauseMessage.appendChild(overlayPauseHeading); - overlayPauseMessage.appendChild(overlayPauseDetail); - this._overlayPauseContainer.appendChild(overlayPauseMessage); - placeholderContents.appendChild(this._overlayPauseContainer); + // Collect and stack container contents from contributions. + const contents: IBrowserContainerContent[] = []; + for (const contribution of this._contributionInstances.values()) { + contents.push(...contribution.containerContents); + } + contents.sort((a, b) => a.order - b.order); + for (const content of contents) { + placeholderContents.appendChild(content.element); + } // Create error container (hidden by default) this._errorContainer = $('.browser-error-container'); @@ -517,59 +573,18 @@ export class BrowserEditor extends EditorPane { // Create welcome container (shown when no URL is loaded) this._welcomeContainer = this.createWelcomeContainer(); placeholderContents.appendChild(this._welcomeContainer); - - this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => { - // When the browser container gets focus, make sure the browser view also gets focused. - // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). - if (event.relatedTarget && this._model && this.shouldShowView) { - this.requestFocus(); - } - })); - - this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { - // If the container becomes blurred, cancel any scheduled focus call. - // This can happen when e.g. a menu closes and focus shifts back to the browser, then immediately focuses another element. - this.cancelFocus(); - })); - - // Register external focus checker so that cross-window focus logic knows when - // this browser view has focus (since it's outside the normal DOM tree). - // Include window info so that UI like dialogs appear in the correct window. - this._register(registerExternalFocusChecker(() => ({ - hasFocus: this._model?.focused ?? false, - window: this._model?.focused ? this.window : undefined - }))); } override focus(): void { if (this._model?.url && !this._model.error) { - this.requestFocus(); + for (const c of this._contributionInstances.values()) { + c.focusPage(); + } } else { this.focusUrlInput(); } } - private _focusTimeout: ReturnType | undefined; - private requestFocus(): void { - this.ensureBrowserFocus(); - if (this._focusTimeout) { - return; - } - this._focusTimeout = setTimeout(() => { - this._focusTimeout = undefined; - if (this._model) { - void this._model.focus(); - } - }, 0); - } - - private cancelFocus(): void { - if (this._focusTimeout) { - clearTimeout(this._focusTimeout); - this._focusTimeout = undefined; - } - } - override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (token.isCancellationRequested) { @@ -609,7 +624,6 @@ export class BrowserEditor extends EditorPane { canGoForward: this._model.canGoForward, certificateError: this._model.certificateError }); - this.setBackgroundImage(this._model.screenshot); // When closing a tab, the model gets disposed before the editor input is cleared. // So we make sure we don't keep a reference to the disposed model. @@ -617,15 +631,6 @@ export class BrowserEditor extends EditorPane { this._model = undefined; })); - // Start / stop screenshots when the model visibility changes - this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); - - // Listen to model events for UI updates - this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => { - // Handle like webview does - convert to webview KeyEvent format - this.handleKeyEventFromBrowserView(keyEvent); - })); - this._inputDisposables.add(this._model.onDidNavigate((navEvent: IBrowserViewNavigationEvent) => { this.group.pinEditor(this.input); // pin editor on navigation @@ -637,19 +642,6 @@ export class BrowserEditor extends EditorPane { this.updateErrorDisplay(); })); - this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => { - // When the view gets focused, make sure the editor reports that it has focus, - // but focus is removed from the workbench. - if (focused) { - this._onDidFocus?.fire(); - this.ensureBrowserFocus(); - } - })); - - this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { - this.checkOverlays(); - })); - // Listen for workbench zoom level changes and update browser view placeholder screenshot's zoom factor this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => { if (targetWindowId === this.window.vscodeWindowId) { @@ -665,11 +657,12 @@ export class BrowserEditor extends EditorPane { this.updateErrorDisplay(); this.layout(); this.updateVisibility(); - this.doScreenshot(); } protected override setEditorVisible(visible: boolean): void { - this._editorVisible = visible; + for (const c of this._contributionInstances.values()) { + c.onPaneVisibilityChanged(visible); + } this.updateVisibility(); } @@ -680,71 +673,22 @@ export class BrowserEditor extends EditorPane { originalHtmlElementFocus.call(this._browserContainer); } - private updateVisibility(): void { - const hasUrl = !!this._model?.url; - const hasError = !!this._model?.error; - const isViewingPage = !hasError && hasUrl; - const isPaused = isViewingPage && this._editorVisible && this._overlayVisible; + /** + * Notify the editor pane that focus has landed on the page content. + * The renderer-providing contribution calls this when the underlying + * page reports focus, since the page lives outside the DOM focus tracker + * and so doesn't propagate through {@link EditorPane.onDidFocus}. + */ + notifyPageFocused(): void { + this._onDidFocus?.fire(); + } + private updateVisibility(): void { // Welcome container: shown when no URL is loaded - this._welcomeContainer.style.display = hasUrl ? 'none' : ''; + this._welcomeContainer.style.display = this._model?.url ? 'none' : ''; // Error container: shown when there's a load error - this._errorContainer.style.display = hasError ? '' : 'none'; - - // Placeholder screenshot: shown when there is a page loaded (even when the view is not hidden, so hiding is smooth) - this._placeholderScreenshot.style.display = isViewingPage ? '' : 'none'; - - // Pause overlay: fades in when an overlay is detected - this._overlayPauseContainer.classList.toggle('visible', isPaused); - - if (this._model) { - const show = this.shouldShowView; - if (show === this._model.visible) { - return; - } - - if (show) { - this._model.setVisible(true); - if ( - this._browserContainer.ownerDocument.hasFocus() && - this._browserContainer.ownerDocument.activeElement === this._browserContainer - ) { - // If the editor is focused, ensure the browser view also gets focus - this.requestFocus(); - } - } else { - this.doScreenshot(); - - // Hide the browser view just before the next render. - // This attempts to give the screenshot some time to be captured and displayed. - // If we hide immediately it is more likely to flicker while the old screenshot is still visible. - this.window.requestAnimationFrame(() => this._model?.setVisible(false)); - } - } - } - - private get shouldShowView(): boolean { - return this._editorVisible && !this._overlayVisible && !this._model?.error && !!this._model?.url; - } - - private checkOverlays(): void { - if (!this.overlayManager) { - return; - } - const overlappingOverlays = this.overlayManager.getOverlappingOverlays(this._browserContainer); - const hasOverlappingOverlay = overlappingOverlays.length > 0; - this.updateOverlayPauseMessage(overlappingOverlays); - if (hasOverlappingOverlay !== this._overlayVisible) { - this._overlayVisible = hasOverlappingOverlay; - this.updateVisibility(); - } - } - - private updateOverlayPauseMessage(overlappingOverlays: readonly IBrowserOverlayInfo[]): void { - // Only show the pause message for notification overlays - const hasNotificationOverlay = overlappingOverlays.some(overlay => overlay.type === BrowserOverlayType.Notification); - this._overlayPauseContainer.classList.toggle('show-message', hasNotificationOverlay); + this._errorContainer.style.display = this._model?.error ? '' : 'none'; } private updateErrorDisplay(): void { @@ -871,10 +815,6 @@ export class BrowserEditor extends EditorPane { } this._errorContainer.appendChild(errorContent); - - this.setBackgroundImage(undefined); - } else { - this.setBackgroundImage(this._model.screenshot); } this.updateVisibility(); @@ -980,62 +920,10 @@ export class BrowserEditor extends EditorPane { return container; } - private setBackgroundImage(buffer: VSBuffer | undefined): void { - if (buffer) { - const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; - this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`; - } else { - this._placeholderScreenshot.style.backgroundImage = ''; - } - } - - private async doScreenshot(): Promise { - if (!this._model) { - return; - } - - // Cancel any existing timeout - this.cancelScheduledScreenshot(); - - // Only take screenshots if the model is visible - if (!this._model.visible) { - return; - } - - try { - // Capture screenshot and set as background image - const screenshot = await this._model.captureScreenshot({ quality: 80 }); - this.setBackgroundImage(screenshot); - } catch (error) { - this.logService.error('Failed to capture browser view screenshot', error); - } - - // Schedule next screenshot in 1 second - this._screenshotTimeout = setTimeout(() => this.doScreenshot(), 1000); - } - - private cancelScheduledScreenshot(): void { - if (this._screenshotTimeout) { - clearTimeout(this._screenshotTimeout); - this._screenshotTimeout = undefined; - } - } - - private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise { - try { - const syntheticEvent = new KeyboardEvent('keydown', keyEvent); - const standardEvent = new StandardKeyboardEvent(syntheticEvent); - - this.keybindingService.dispatchEvent(standardEvent, this._browserContainer); - } catch (error) { - this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error); - } - } - override layout(dimension?: Dimension, _position?: IDomPosition): void { if (dimension) { for (const contribution of this._contributionInstances.values()) { - contribution.layout(dimension.width); + contribution.onPaneResized(dimension.width); } } @@ -1052,38 +940,32 @@ export class BrowserEditor extends EditorPane { /** * Recompute the layout of the browser container and push the resulting - * bounds + emulation to the WebContentsView. Should generally only be - * called via {@link layout} so the container is fully styled first. + * bounds + emulation to the renderer. Should generally only be called + * via {@link layout} so the container is fully styled first. */ layoutBrowserContainer(retries = 2): void { if (!this._model) { return; } - this.checkOverlays(); - // Pick the first contribution that wants to override sizing. - let override: IContainerLayoutOverride | undefined; + const overrides: IContainerLayoutOverride[] = []; for (const c of this._contributionInstances.values()) { - const o = c.getContainerLayoutOverride(); + const o = c.beforeContainerLayout(); if (o) { - override = o; - break; + overrides.push(o); } } - // Apply the wrapper padding the editor will assume below. Inline style - // is the single source of truth — the wrapper's CSS has no padding. - // Right/bottom/left are clamped so the container always has breathing - // room (and resize sashes that sit on those edges remain reachable). - const raw = override?.padding; - const padding = { - top: raw?.top ?? 0, - right: Math.max(3, raw?.right ?? 0), - bottom: Math.max(3, raw?.bottom ?? 0), - left: Math.max(3, raw?.left ?? 0), - }; + // Take the per-side max of padding contributions so each reservation is + // honoured without double-counting overlapping widgets. + const padding = { top: 0, right: 0, bottom: 0, left: 0 }; + for (const o of overrides) { + padding.top = Math.max(padding.top, o.padding?.top ?? 0); + padding.right = Math.max(padding.right, o.padding?.right ?? 0); + padding.bottom = Math.max(padding.bottom, o.padding?.bottom ?? 0); + padding.left = Math.max(padding.left, o.padding?.left ?? 0); + } this._currentPadding = padding; - this._browserContainerWrapper.style.padding = `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`; const wrapperRect = this._browserContainerWrapper.getBoundingClientRect(); if ((wrapperRect.width === 0 || wrapperRect.height === 0) && retries > 0) { @@ -1092,41 +974,55 @@ export class BrowserEditor extends EditorPane { return; } + // Chain compute callbacks in priority order over the area available + // after padding. layout.top/left are local to the available area; pane + // info also carries the absolute screen origin so contributions can + // reason about pixel alignment. const paneWidth = Math.max(0, wrapperRect.width - padding.left - padding.right); const paneHeight = Math.max(0, wrapperRect.height - padding.top - padding.bottom); - let layout: IContainerLayout; - if (override) { - layout = override.compute(paneWidth, paneHeight); - } else { - const z = getZoomFactor(this.window); - const snap = (v: number) => Math.floor(v * z) / z; - layout = { width: snap(paneWidth), height: snap(paneHeight) }; + const pane: IContainerLayoutPane = { + width: paneWidth, + height: paneHeight, + originX: wrapperRect.left + padding.left, + originY: wrapperRect.top + padding.top, + }; + const sorted = overrides.slice().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); + let layout: IContainerLayout = { width: paneWidth, height: paneHeight, top: 0, left: 0 }; + for (const o of sorted) { + const next = o.compute?.(layout, pane); + if (next) { + layout = next; + } } - // Size the container, then derive its absolute screen rect analytically: - // the wrapper's flex rules center the container within the pane. + const left = padding.left + (layout.left ?? 0); + const top = padding.top + (layout.top ?? 0); + this._browserContainer.style.width = `${layout.width}px`; this._browserContainer.style.height = `${layout.height}px`; - const containerLeft = wrapperRect.left + padding.left + (paneWidth - layout.width) / 2; - const containerTop = wrapperRect.top + padding.top + (paneHeight - layout.height) / 2; + this._browserContainer.style.left = `${left}px`; + this._browserContainer.style.top = `${top}px`; + const cornerRadius = parseFloat(this.window.getComputedStyle(this._browserContainer).borderTopLeftRadius ?? '0'); void this._model.layout({ windowId: this.group.windowId, - x: containerLeft, - y: containerTop, + x: wrapperRect.left + left, + y: wrapperRect.top + top, width: layout.width, height: layout.height, zoomFactor: getZoomFactor(this.window), cornerRadius, emulation: layout.emulation, }); + + for (const c of this._contributionInstances.values()) { + c.afterContainerLayout(); + } } /** - * Wrapper content-area size in CSS px — the maximum room the container - * can occupy after the active padding is applied. Derived from the last - * padding we wrote to the wrapper, so it stays in sync without re-reading - * the computed style. + * Wrapper content-area size in CSS px — the area available to layout + * contributions after their aggregated padding is applied. */ get paneSize(): { width: number; height: number } { const r = this._browserContainerWrapper.getBoundingClientRect(); @@ -1140,11 +1036,6 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - // Cancel any scheduled timers - this.cancelScheduledScreenshot(); - this.cancelFocus(); - - void this._model?.setVisible(false); this._model = undefined; this._onDidChangeModel.fire({ model: undefined, isNew: false }); @@ -1154,7 +1045,6 @@ export class BrowserEditor extends EditorPane { this._hasErrorContext.reset(); this._navigationBar.clear(); - this.setBackgroundImage(undefined); super.clearInput(); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index f2eee43e29793..21bf5bd32b0df 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -21,6 +21,7 @@ import { BrowserViewCDPService } from './browserViewCDPService.js'; // Register actions and browser features import './browserViewActions.js'; +import './features/webContentsViewRendererFeature.js'; import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts index 43ec7806d02bc..9243e25409a05 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -31,11 +31,11 @@ class BrowserEditorStorageScopeContribution extends BrowserEditorContribution { this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); } - protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, _store: DisposableStore): void { this._storageScopeContext.set(model.storageScope); } - override clear(): void { + override onModelDetached(): void { this._storageScopeContext.reset(); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts index 6c83a2a3ecdba..9544b07e02aca 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -30,14 +30,14 @@ class BrowserEditorDevToolsContribution extends BrowserEditorContribution { this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); } - protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { this._devToolsOpenContext.set(model.isDevToolsOpen); store.add(model.onDidChangeDevToolsState(e => { this._devToolsOpenContext.set(e.isDevToolsOpen); })); } - override clear(): void { + override onModelDetached(): void { this._devToolsOpenContext.reset(); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 04f86a84f627e..3c1a4f2ebf06b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -159,7 +159,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { return [{ element: this._shareButtonContainer, order: 50 }]; } - protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { // Manage sharing state this._updateSharingState(true); store.add(model.onDidChangeSharingState(() => { @@ -180,7 +180,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { })); } - override clear(): void { + override onModelDetached(): void { this._elementSelectionActiveContext.reset(); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts index 29e2efa8f56c9..03fdea00bfac4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { $, addDisposableListener, EventType, getWindow } from '../../../../../base/browser/dom.js'; -import { getZoomFactor } from '../../../../../base/browser/browser.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { IHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegate.js'; import { ISashEvent, Orientation, OrthogonalEdge, Sash, SashState } from '../../../../../base/browser/ui/sash/sash.js'; @@ -399,7 +398,7 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { return [this._toolbar.element]; } - override onContainerReady(container: HTMLElement): void { + override onContainerCreated(container: HTMLElement): void { this._createResizeSashes(container); const observer = new (getWindow(container).ResizeObserver)(() => { @@ -410,14 +409,15 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { this._register({ dispose: () => observer.disconnect() }); } - override getContainerLayoutOverride(): IContainerLayoutOverride | undefined { + override beforeContainerLayout(): IContainerLayoutOverride | undefined { if (!this.editor.model?.device) { return undefined; } return { // Reserve space for the east + south resize sashes that sit just outside the container. padding: { right: 16, bottom: 16 }, - compute: (w, h) => this._computeLayout(w, h), + compute: (_current, pane) => this._computeLayout(pane.width, pane.height), + priority: 0 }; } @@ -425,8 +425,6 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { const device = this.editor.model?.device; const width = device?.width; const height = device?.height; - const z = getZoomFactor(this.editor.window); - const snap = (v: number) => Math.floor(v * z) / z; const fitScale = paneWidth > 0 && paneHeight > 0 ? Math.min(width ? paneWidth / width : 1, height ? paneHeight / height : 1, 1) : 1; @@ -435,14 +433,20 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { this._onDidChangeAutoFitScale.fire(fitScale); } const scale = this._scale ?? fitScale; + const layoutWidth = width ? Math.min(width * scale, paneWidth) : paneWidth; + const layoutHeight = height ? Math.min(height * scale, paneHeight) : paneHeight; return { - width: snap(width ? Math.min(width * scale, paneWidth) : paneWidth), - height: snap(height ? Math.min(height * scale, paneHeight) : paneHeight), + width: layoutWidth, + height: layoutHeight, + // Center the device within the available pane (the sash reservation + // is already accounted for via padding). + left: Math.max(0, (paneWidth - layoutWidth) / 2), + top: Math.max(0, (paneHeight - layoutHeight) / 2), emulation: { scale }, }; } - protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { this._toolbar.refresh(); this._syncContextKeys(model.device); this._updateSashState(); @@ -464,7 +468,7 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { })); } - override clear(): void { + override onModelDetached(): void { // Editor input is being cleared — drop renderer-side state so a freshly // reopened input starts without stale viewport overrides. this._scale = undefined; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index 89a17a398acaf..9d6389e3e2680 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -236,16 +236,16 @@ export class BrowserEditorFindContribution extends BrowserEditorContribution { return [this._findWidgetContainer]; } - protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, _store: DisposableStore): void { this._findWidget.rawValue?.setModel(model); } - override clear(): void { + override onModelDetached(): void { this._findWidget.rawValue?.setModel(undefined); this._findWidget.rawValue?.hide(); } - override layout(width: number): void { + override onPaneResized(width: number): void { this._findWidget.rawValue?.layout(width); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts index a527d8ab6c4f8..9d8fc1cddb97c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -91,14 +91,14 @@ export class BrowserEditorZoomSupport extends BrowserEditorContribution { return [{ element: this._zoomPill.element, order: 0 }]; } - protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { this._updateZoomContext(model); store.add(model.onDidChangeZoom(() => { this._updateZoomContext(model); })); } - override clear(): void { + override onModelDetached(): void { this._canZoomInContext.reset(); this._canZoomOutContext.reset(); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 8d6cd8195020b..fe6b373876e83 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -675,7 +675,7 @@ class LinkOpenedHintPill extends BrowserEditorContribution { return [{ element: this._pill, order: 100 }]; } - protected override subscribeToModel(_model: IBrowserViewModel, _store: DisposableStore, isNew: boolean): void { + protected override onModelAttached(_model: IBrowserViewModel, _store: DisposableStore, isNew: boolean): void { if (IsSessionsWindowContext.getValue(this.contextKeyService)) { this._setVisible(false); return; @@ -693,7 +693,7 @@ class LinkOpenedHintPill extends BrowserEditorContribution { } } - override clear(): void { + override onModelDetached(): void { this._attentionTimeout.clear(); this._setVisible(false); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts new file mode 100644 index 0000000000000..9cbccbbc44a5a --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -0,0 +1,317 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { $, addDisposableListener, EventType, registerExternalFocusChecker } from '../../../../../base/browser/dom.js'; +import { getZoomFactor } from '../../../../../base/browser/browser.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { encodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { + IBrowserViewKeyDownEvent, +} from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { + BrowserEditor, + BrowserEditorContribution, + IBrowserContainerContent, + IContainerLayout, + IContainerLayoutOverride, +} from '../browserEditor.js'; +import { BrowserOverlayManager, BrowserOverlayType } from '../overlayManager.js'; + +/** + * Default browser renderer: drives a Chromium WebContentsView. + * + * Owns everything that exists only because of how the WCV behaves: + * - placeholder screenshot to mask the page during show/hide swaps, + * - overlay-pause UI for when a workbench modal sits on top of the WCV, + * - the focus dance that bounces focus between the workbench DOM and the WCV, + * - native key event forwarding through the keybinding service, + * - the pixel-snap layout contribution that keeps the container on physical + * pixel boundaries (registered late in the priority chain so it refines + * whatever sizing other contributions produce). + * + * An alternative renderer (e.g. an in-DOM iframe) would replace this + * contribution and need none of the above. + */ +class WebContentsViewRendererFeature extends BrowserEditorContribution { + + private _container: HTMLElement | undefined; + private _model: IBrowserViewModel | undefined; + private _editorVisible = false; + private _overlayObscured = false; + + private readonly _placeholderScreenshot = $('.browser-placeholder-screenshot'); + private readonly _overlayPauseEl = $('.browser-overlay-paused'); + private readonly _overlayManager: BrowserOverlayManager; + + private readonly _placeholderContent: IBrowserContainerContent; + private readonly _overlayPauseContent: IBrowserContainerContent; + + private readonly _screenshotHandle = this._register(new MutableDisposable()); + private _focusTimeout: ReturnType | undefined; + + constructor( + editor: BrowserEditor, + @ILogService private readonly logService: ILogService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(editor); + + this._overlayManager = this._register(new BrowserOverlayManager(editor.window)); + + // Build overlay-pause DOM + const message = $('.browser-overlay-paused-message'); + const heading = $('.browser-overlay-paused-heading'); + const detail = $('.browser-overlay-paused-detail'); + heading.textContent = localize('browser.overlayPauseHeading.notification', "Paused due to Notification"); + detail.textContent = localize('browser.overlayPauseDetail.notification', "Dismiss the notification to continue using the browser."); + message.appendChild(heading); + message.appendChild(detail); + this._overlayPauseEl.appendChild(message); + + this._placeholderContent = { element: this._placeholderScreenshot, order: 100 }; + this._overlayPauseContent = { element: this._overlayPauseEl, order: 200 }; + + this._register(this._overlayManager.onDidChangeOverlayState(() => this._refreshOverlayObscured())); + this._refresh(); + } + + override get containerContents(): readonly IBrowserContainerContent[] { + return [this._placeholderContent, this._overlayPauseContent]; + } + + override beforeContainerLayout(): IContainerLayoutOverride { + return { + padding: { right: 3, bottom: 3, left: 3 }, + + // Snap CSS-pixel values down so `v × hostZoom` is an exact integer: + // main places the WCV at `round(v × hostZoom) × systemDPR` physical + // pixels while CSS renders it at `v × hostZoom × systemDPR`, so this + // collapses main's rounding to a no-op and keeps the WebContentsView + // aligned with the placeholder screenshot. We snap the absolute + // origin (pane origin + local offset) then derive the corresponding + // local position so the DOM element and the WCV land on the same + // physical pixel. Runs late so it refines whatever sizing upstream + // contributions (e.g. device emulation) produced. + compute: (current, pane): IContainerLayout => { + const z = getZoomFactor(this.editor.window); + const snap = (v: number) => Math.floor(v * z) / z; + const absLeft = pane.originX + (current.left ?? 0); + const absTop = pane.originY + (current.top ?? 0); + return { + ...current, + width: snap(current.width), + height: snap(current.height), + left: snap(absLeft) - pane.originX, + top: snap(absTop) - pane.originY, + }; + }, + priority: 1000, + }; + } + + override onContainerCreated(container: HTMLElement): void { + this._container = container; + + this._register(addDisposableListener(container, EventType.FOCUS, (event: FocusEvent) => { + // When the browser container gets focus, make sure the browser view also gets focused — + // but only if focus was already in the workbench (and not e.g. clicking back into the + // workbench from the browser view itself). + if (event.relatedTarget && this._model && this._shouldShowPage()) { + this.focusPage(); + } + })); + this._register(addDisposableListener(container, EventType.BLUR, () => this._cancelFocusTimeout())); + + // Cross-window focus logic uses this checker because the WCV lives + // outside the DOM tree and can't be detected with activeElement. + this._register(registerExternalFocusChecker(() => ({ + hasFocus: this._model?.focused ?? false, + window: this._model?.focused ? this.editor.window : undefined, + }))); + + this._refreshOverlayObscured(); + } + + // -- Base contribution hooks -------------------------------------------- + + override onPaneVisibilityChanged(visible: boolean): void { + if (this._editorVisible === visible) { + return; + } + this._editorVisible = visible; + this._refresh(); + } + + override afterContainerLayout(): void { + // Container moved or resized — overlays that overlap us might have + // shifted relative to the container even though their own DOM didn't + // change. Recompute obscured state so the page can hide accordingly. + this._refreshOverlayObscured(); + } + + override focusPage(): void { + this.editor.ensureBrowserFocus(); + if (this._focusTimeout || !this._model) { + return; + } + this._focusTimeout = setTimeout(() => { + this._focusTimeout = undefined; + if (this._model) { + void this._model.focus(); + } + }, 0); + } + + // -- Model lifecycle ---------------------------------------------------- + + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { + this._model = model; + this._setBackgroundImage(model.screenshot); + + store.add(model.onDidChangeVisibility(() => void this._doScreenshot())); + store.add(model.onDidKeyCommand(keyEvent => void this._handleKeyEvent(keyEvent))); + store.add(model.onDidChangeFocus(({ focused }) => { + if (focused) { + // The WCV lives outside the DOM focus tracker, so the editor + // pane won't otherwise observe page focus. Notify it directly. + this.editor.notifyPageFocused(); + this.editor.ensureBrowserFocus(); + } + })); + store.add(model.onDidNavigate(() => this._refresh())); + store.add(model.onDidChangeLoadingState(() => this._refresh())); + + this._refresh(); + void this._doScreenshot(); + } + + override onModelDetached(): void { + if (this._model) { + void this._model.setVisible(false); + } + this._model = undefined; + this._screenshotHandle.clear(); + this._cancelFocusTimeout(); + this._setBackgroundImage(undefined); + this._refresh(); + } + + override dispose(): void { + this._cancelFocusTimeout(); + super.dispose(); + } + + // -- Internals ---------------------------------------------------------- + + private _shouldShowPage(): boolean { + return this._editorVisible + && !this._overlayObscured + && !!this._model?.url + && !this._model?.error; + } + + /** + * Recompute visibility of our content layers and the underlying page based + * on the latest editor/overlay/model state. + */ + private _refresh(): void { + // Placeholder screenshot: shown whenever there's a page to render + // (covered by the WCV when it's up, visible during hide/show swaps). + const placeholderActive = !!this._model?.url && !this._model?.error; + this._placeholderScreenshot.style.display = placeholderActive ? '' : 'none'; + + // Overlay-pause overlay: fades in when an overlay obscures the page. + const pauseActive = !!this._model?.url && this._editorVisible && this._overlayObscured; + this._overlayPauseEl.classList.toggle('visible', pauseActive); + + if (!this._model) { + return; + } + const show = this._shouldShowPage(); + if (show === this._model.visible) { + return; + } + if (show) { + void this._model.setVisible(true); + // If the editor container is focused, ensure the WCV gets focus too. + const ownerDoc = this._container?.ownerDocument; + if (ownerDoc?.hasFocus() && ownerDoc.activeElement === this._container) { + this.focusPage(); + } + } else { + void this._doScreenshot(); + // Defer the hide one frame so the latest screenshot has a chance to paint first. + this.editor.window.requestAnimationFrame(() => void this._model?.setVisible(false)); + } + } + + private _refreshOverlayObscured(): void { + if (!this._container) { + return; + } + const overlays = this._overlayManager.getOverlappingOverlays(this._container); + const obscured = overlays.length > 0; + const hasNotification = overlays.some(o => o.type === BrowserOverlayType.Notification); + this._overlayPauseEl.classList.toggle('show-message', hasNotification); + if (obscured !== this._overlayObscured) { + this._overlayObscured = obscured; + this._refresh(); + } + } + + private async _doScreenshot(): Promise { + if (!this._model) { + return; + } + this._screenshotHandle.clear(); + if (!this._model.visible) { + return; + } + try { + const screenshot = await this._model.captureScreenshot({ quality: 80 }); + this._setBackgroundImage(screenshot); + } catch (error) { + this.logService.error('Failed to capture browser view screenshot', error); + } + const handle = setTimeout(() => void this._doScreenshot(), 1000); + this._screenshotHandle.value = toDisposable(() => clearTimeout(handle)); + } + + private _setBackgroundImage(buffer: VSBuffer | undefined): void { + if (buffer) { + const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; + this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`; + } else { + this._placeholderScreenshot.style.backgroundImage = ''; + } + } + + private async _handleKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { + if (!this._container) { + return; + } + try { + const syntheticEvent = new KeyboardEvent('keydown', keyEvent); + const standardEvent = new StandardKeyboardEvent(syntheticEvent); + this.keybindingService.dispatchEvent(standardEvent, this._container); + } catch (error) { + this.logService.error('WebContentsViewRendererFeature: Error dispatching key event', error); + } + } + + private _cancelFocusTimeout(): void { + if (this._focusTimeout) { + clearTimeout(this._focusTimeout); + this._focusTimeout = undefined; + } + } +} + +BrowserEditor.registerContribution(WebContentsViewRendererFeature); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 3b1916a5687a6..c1205dd14670e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -311,16 +311,17 @@ min-height: 0; position: relative; margin-top: 1px; - display: flex; - align-items: center; - justify-content: center; box-sizing: border-box; } .browser-container { overflow: visible; border-radius: var(--vscode-cornerRadius-small); - position: relative; + /* Absolutely positioned within the wrapper; size and position are set + inline from JS (already snapped to the physical-pixel grid) so the + WebContentsView placed by the main process and the placeholder + screenshot align on the same pixels. */ + position: absolute; outline: none !important; z-index: 0; /* Important: creates a new stacking context for the gradient border trick */ @@ -443,7 +444,7 @@ right: 0; bottom: 0; background-image: none; - background-size: contain; + background-size: 100% 100%; background-repeat: no-repeat; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 7d8e0512eafb1..fd82cc29067ab 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -319,7 +319,7 @@ export async function showToolsPicker( itemType: 'bucket', ordinal: BucketOrdinal.Mcp, id: key, - label: source.label, + label: source.serverLabel || source.label, checked: undefined, collapsed, children, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index f73980dbefbe2..03e839980da73 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { CustomizationStatus, StateComponents, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, StateComponents, type AgentInfo, type ClientPluginCustomization, type Customization, type CustomizationLoadState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ICustomizationAgentRef, ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; @@ -32,7 +32,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private _agentCustomizations: readonly CustomizationRef[]; + private _agentCustomizations: readonly Customization[]; /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>(); @@ -44,7 +44,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto private readonly _connectionAuthority: string, private readonly _fileService: IFileService, private readonly _logService: ILogService, - private readonly _getItemActions?: (customization: CustomizationRef, clientId: string | undefined) => ICustomizationItemAction[] | undefined, + private readonly _getItemActions?: (customization: Customization, clientId: string | undefined) => ICustomizationItemAction[] | undefined, ) { super(); this._contentExpander = new AgentCustomizationContentExpander(this._fileService, this._logService); @@ -67,7 +67,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } - private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined { + private _readRootCustomizations(rootState: RootState | Error | undefined): readonly Customization[] | undefined { if (!rootState || rootState instanceof Error || !rootState.config) { return undefined; } @@ -75,7 +75,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return getAgentHostConfiguredCustomizations(rootState.config?.values); } - private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined { + private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly Customization[] | undefined { if (!rootState || rootState instanceof Error) { return undefined; } @@ -95,7 +95,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return toAgentHostUri(original, this._connectionAuthority); } - private toBadge(customization: CustomizationRef, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } { + private toBadge(customization: Customization, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } { if (fromClient) { return { groupKey: REMOTE_CLIENT_GROUP, @@ -107,20 +107,20 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }; } - private toItem(customization: CustomizationRef, source: AICustomizationSource, sessionCustomization?: SessionCustomization): ICustomizationItem { - const clientId = sessionCustomization?.clientId; // set if the configuration came from the client + private toItem(customization: Customization, source: AICustomizationSource): ICustomizationItem { + const clientId = customization.clientId; // set if the configuration came from the client const badge = this.toBadge(customization, clientId !== undefined); const uri = this.toRemoteUri(customization.uri); return { itemKey: customizationItemKey(customization, clientId), uri: uri, type: 'plugin', - name: customization.displayName, - description: customization.description, + name: customization.name, + description: undefined, source, - status: toStatusString(sessionCustomization?.status), - statusMessage: sessionCustomization?.statusMessage, - enabled: sessionCustomization?.enabled ?? true, + status: toStatusString(customization.load), + statusMessage: toStatusMessage(customization.load), + enabled: customization.enabled, badge: badge.badge, badgeTooltip: badge.badgeTooltip, groupKey: badge.groupKey, @@ -135,7 +135,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return AgentSession.uri(this._agentInfo.provider, rawId); } - private getSessionCustomizations(sessionResource: URI): readonly SessionCustomization[] { + private getSessionCustomizations(sessionResource: URI): readonly Customization[] { const sessionUri = this._resolveSessionUri(sessionResource); const sessionState = this._connection.getSubscriptionUnmanaged(StateComponents.Session, sessionUri)?.value; return sessionState && !(sessionState instanceof Error) ? sessionState.customizations ?? [] : []; @@ -143,7 +143,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto async provideCustomAgents(sessionResource: URI): Promise { const sessionCustomizations = this.getSessionCustomizations(sessionResource); - const agents = sessionCustomizations.flatMap(c => c.agents ?? []); + const agents = sessionCustomizations.flatMap(c => c.children?.filter(child => child.type === CustomizationType.Agent) ?? []); return agents.map(agent => ({ uri: this.toRemoteUri(agent.uri), name: agent.name, @@ -163,7 +163,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto items.set(customizationItemKey(customization, undefined), item); const pluginMeta = { item, - nonce: customization.nonce, + nonce: (customization as ClientPluginCustomization).nonce, status: undefined, statusMessage: undefined, enabled: undefined, @@ -174,7 +174,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto expandPromises.push(this._expandPluginContents(pluginMeta, token)); } for (const sessionCustomization of this.getSessionCustomizations(sessionResource)) { - const isBundleItem = isSyntheticBundle(sessionCustomization.customization); + const isBundleItem = isSyntheticBundle(sessionCustomization); const isClientSynced = sessionCustomization.clientId !== undefined; const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; @@ -185,17 +185,17 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto // expanded below so individual user files appear in per-type tabs. let item: ICustomizationItem; if (!isBundleItem) { - item = this.toItem(sessionCustomization.customization, AICustomizationSources.plugin, sessionCustomization); - items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item); + item = this.toItem(sessionCustomization, AICustomizationSources.plugin); + items.set(customizationItemKey(sessionCustomization, sessionCustomization.clientId), item); } else { // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand. - item = { uri: this.toRemoteUri(sessionCustomization.customization.uri), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; + item = { uri: this.toRemoteUri(sessionCustomization.uri), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; } const pluginMeta = { item, - nonce: sessionCustomization.customization.nonce, - status: toStatusString(sessionCustomization.status), - statusMessage: sessionCustomization.statusMessage, + nonce: (sessionCustomization as ClientPluginCustomization).nonce, + status: toStatusString(sessionCustomization.load), + statusMessage: toStatusMessage(sessionCustomization.load), enabled: sessionCustomization.enabled, childGroupKey, isBundleItem @@ -243,21 +243,22 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } } -function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { - switch (status) { - case CustomizationStatus.Loading: return 'loading'; - case CustomizationStatus.Loaded: return 'loaded'; - case CustomizationStatus.Degraded: return 'degraded'; - case CustomizationStatus.Error: return 'error'; - default: return undefined; +function toStatusString(load: CustomizationLoadState | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { + return load?.kind; +} + +function toStatusMessage(load: CustomizationLoadState | undefined): string | undefined { + if (load?.kind === CustomizationLoadStatus.Degraded || load?.kind === CustomizationLoadStatus.Error) { + return load.message; } + return undefined; } -function customizationKey(customization: CustomizationRef): string { - return customization.uri; +function customizationKey(customization: Customization): string { + return customization.id; } -function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string { +function customizationItemKey(customization: Customization, clientId: string | undefined): string { return clientId !== undefined ? `${customizationKey(customization)}::${clientId}` : customizationKey(customization); @@ -268,7 +269,7 @@ function customizationItemKey(customization: CustomizationRef, clientId: string * which is an implementation detail of the customization sync pipeline * and should not be surfaced as a standalone item in the UI. */ -function isSyntheticBundle(customization: CustomizationRef): boolean { +function isSyntheticBundle(customization: Customization): boolean { try { return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME; } catch { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index d8a8ec3a4b0e3..cb6f468e915bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -13,7 +13,8 @@ import { InstantiationType, registerSingleton } from '../../../../../../platform import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; -import type { CustomizationRef, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -48,7 +49,7 @@ export interface IAgentHostActiveClientService { /** Returns a {@link SessionActiveClient} for `sessionType` using the caller-supplied `clientId`. Customizations are empty when `sessionType` has not been registered. */ getActiveClient(sessionType: string, clientId: string): SessionActiveClient; - getCustomizations(sessionType: string): IObservable; + getCustomizations(sessionType: string): IObservable; readonly clientTools: IObservable; } @@ -56,7 +57,7 @@ export interface IAgentHostActiveClientService { export class AgentHostActiveClientService extends Disposable implements IAgentHostActiveClientService { declare readonly _serviceBrand: undefined; - private readonly _customizationsByType: ISettableObservable>>; + private readonly _customizationsByType: ISettableObservable>>; readonly clientTools: IObservable; constructor( @@ -85,7 +86,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const store = new DisposableStore(); const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); - const customizations = observableValue('agentCustomizations', []); + const customizations = observableValue('agentCustomizations', []); let updateSeq = 0; const updateCustomizations = async () => { const seq = ++updateSeq; @@ -117,7 +118,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo }; } - private _setCustomizations(sessionType: string, customizations: IObservable): IDisposable { + private _setCustomizations(sessionType: string, customizations: IObservable): IDisposable { const next = new Map(this._customizationsByType.get()); next.set(sessionType, customizations); this._customizationsByType.set(next, undefined); @@ -140,11 +141,11 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo }; } - getCustomizations(sessionType: string): IObservable { + getCustomizations(sessionType: string): IObservable { return derived(reader => this._customizationsByType.read(reader).get(sessionType)?.read(reader) ?? EMPTY_CUSTOMIZATIONS); } } -const EMPTY_CUSTOMIZATIONS: readonly CustomizationRef[] = Object.freeze([]); +const EMPTY_CUSTOMIZATIONS: readonly ClientPluginCustomization[] = Object.freeze([]); registerSingleton(IAgentHostActiveClientService, AgentHostActiveClientService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts index 7504c619fe7f9..52f215ba3d947 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts @@ -20,7 +20,7 @@ import { IAgentHostService } from '../../../../../../platform/agentHost/common/a import { agentHostAgentPickerStorageKey, getEffectiveAgents, resolveAgentHostAgent } from '../../../../../../platform/agentHost/common/customAgents.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import type { CustomizationAgentRef, SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { AgentCustomization, SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -80,7 +80,7 @@ function toBackendSessionUri(sessionResource: URI): URI | undefined { */ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { - private readonly _currentAgent = observableValue('agentHostCurrentAgent', undefined); + private readonly _currentAgent = observableValue('agentHostCurrentAgent', undefined); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); /** Captured at construction so the footer menu doesn't depend on a private parent field. */ private readonly _ctxKeyService: IContextKeyService; @@ -261,7 +261,7 @@ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActi return value && !(value instanceof Error) ? value : undefined; } - private _currentAgents(): readonly CustomizationAgentRef[] { + private _currentAgents(): readonly AgentCustomization[] { return getEffectiveAgents(this._readState()?.customizations); } @@ -279,7 +279,7 @@ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActi this._currentAgent.set(resolved, undefined); } - private _userSetAgent(agent: CustomizationAgentRef | undefined): void { + private _userSetAgent(agent: AgentCustomization | undefined): void { const resource = this._sessionResource(); const backend = resource ? this._resolveBackend(resource) : undefined; if (!resource || !backend) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 5eb615ab1c1be..13f9d55fc8283 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -6,8 +6,8 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptPath, IPromptsService, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; @@ -134,7 +134,7 @@ export async function resolveCustomizationRefs( agentPluginService: IAgentPluginService, bundler: SyncedCustomizationBundler, sessionType: string, -): Promise { +): Promise { const enumerated = await enumerateLocalCustomizationsForHarness(promptsService, syncProvider, sessionType, CancellationToken.None); const enabled = enumerated.filter(e => !e.disabled); if (enabled.length === 0) { @@ -142,7 +142,7 @@ export async function resolveCustomizationRefs( } const plugins = agentPluginService.plugins.get(); - const pluginRefs = new Map(); + const pluginRefs = new Map(); const looseFiles: { uri: URI; type: PromptsType }[] = []; for (const entry of enabled) { @@ -156,14 +156,20 @@ export async function resolveCustomizationRefs( } const key = plugin.uri.toString(); if (!pluginRefs.has(key)) { - pluginRefs.set(key, { uri: key as ProtocolURI, displayName: plugin.label }); + pluginRefs.set(key, { + type: CustomizationType.Plugin, + id: customizationId(key), + uri: key as ProtocolURI, + name: plugin.label, + enabled: true, + }); } } else { looseFiles.push({ uri: entry.uri, type: entry.type }); } } - const refs: CustomizationRef[] = [...pluginRefs.values()]; + const refs: ClientPluginCustomization[] = [...pluginRefs.values()]; if (looseFiles.length > 0) { const result = await bundler.bundle(looseFiles); if (result) { 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 9d16340c982d6..3c3b30960e4e4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -23,10 +23,10 @@ import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../ import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -931,7 +931,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * role for this session and publish the current customizations and * client-provided tools. */ - private _dispatchActiveClient(backendSession: URI, customizations: CustomizationRef[]): void { + private _dispatchActiveClient(backendSession: URI, customizations: ClientPluginCustomization[]): void { const current = this._getCurrentActiveClient(); this._dispatchAction(backendSession, { type: ActionType.SessionActiveClientChanged, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts index b473d3982c9f1..4ca9c8c31d485 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts @@ -10,8 +10,8 @@ import { URI } from '../../../../../../base/common/uri.js'; import { hash } from '../../../../../../base/common/hash.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; // Re-export so existing consumers don't need to change their import source. @@ -48,7 +48,7 @@ interface ISyncableFile { } interface IBundleResult { - readonly ref: CustomizationRef; + readonly ref: ClientPluginCustomization; } /** @@ -98,7 +98,7 @@ export class SyncedCustomizationBundler extends Disposable { /** * Bundles the given files into the in-memory plugin filesystem. * - * Overwrites any previous bundle content. Returns a {@link CustomizationRef} + * Overwrites any previous bundle content. Returns a {@link ClientPluginCustomization} * pointing at the virtual plugin directory with a content-based nonce. * * @returns The bundle result, or `undefined` if no syncable files were provided. @@ -155,11 +155,14 @@ export class SyncedCustomizationBundler extends Disposable { this._lastNonce = nonce; + const rootUriString = this._rootUri.toString() as ProtocolURI; return { ref: { - uri: this._rootUri.toString() as ProtocolURI, - displayName: DISPLAY_NAME, - description: `${syncable.length} customization(s) synced from VS Code`, + type: CustomizationType.Plugin, + id: customizationId(rootUriString), + uri: rootUriString, + name: DISPLAY_NAME, + enabled: true, nonce, }, }; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index d8472fb23658e..c1b8b0e64744d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -40,7 +40,7 @@ import { IEditorResolverService, RegisteredEditorPriority } from '../../../servi import { IPathService } from '../../../services/path/common/pathService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { AddConfigurationType, AssistedTypes } from '../../mcp/browser/mcpCommandsAddConfiguration.js'; -import { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; +import { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpEnterpriseManagedAuthIdpSection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; import '../common/widget/chatColors.js'; @@ -147,6 +147,7 @@ import { QuickChatService } from './widgetHosts/chatQuick.js'; import { ChatResponseAccessibleView } from './accessibility/chatResponseAccessibleView.js'; import { ChatTerminalOutputAccessibleView } from './accessibility/chatTerminalOutputAccessibleView.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; +import { ChatQuotaNotificationContribution } from './chatQuotaNotification.js'; import { HasByokModelsContribution } from './hasByokModelsContribution.js'; import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; import { ChatVariablesService } from './attachments/chatVariables.js'; @@ -785,6 +786,41 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, + [mcpEnterpriseManagedAuthIdpSection]: { + type: 'object', + default: {}, + scope: ConfigurationScope.APPLICATION, + tags: ['preview', 'experimental'], + additionalProperties: false, + included: false, + properties: { + issuer: { + type: 'string', + format: 'uri', + markdownDescription: nls.localize('mcp.enterpriseManagedAuth.idp.issuer', "The OAuth/OIDC issuer URL of the SSO authorization server. Must be an `https://` URL."), + }, + clientId: { + type: 'string', + markdownDescription: nls.localize('mcp.enterpriseManagedAuth.idp.clientId', "The OAuth client ID registered with the SSO issuer for this device."), + }, + clientSecret: { + type: 'string', + markdownDescription: nls.localize('mcp.enterpriseManagedAuth.idp.clientSecret', "The OAuth client secret paired with `clientId`. Intended for local development only."), + }, + }, + markdownDescription: nls.localize('mcp.enterpriseManagedAuth.idp', "(Preview) The OAuth/OIDC IdP configuration used for enterprise-managed Model Context Protocol (MCP) servers. Typically delivered via enterprise policy (Windows Group Policy / macOS managed preferences / Linux `/etc/vscode/policy.json`); developers may hand-edit `settings.json` for local testing. Properties: `issuer` (HTTPS URL), `clientId`, `clientSecret`."), + policy: { + name: 'McpEnterpriseManagedAuthIdp', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.122', + localization: { + description: { + key: 'mcp.enterpriseManagedAuth.idp.policy', + value: nls.localize('mcp.enterpriseManagedAuth.idp.policy', "The OAuth/OIDC IdP configuration used for enterprise-managed Model Context Protocol (MCP) server authentication. Delivered through enterprise policy (Windows Group Policy, macOS managed preferences, Linux `/etc/vscode/policy.json`)."), + } + } + }, + }, [mcpServerCollisionBehaviorSection]: { type: 'string', description: nls.localize('chat.mcp.collisionBehavior', "Controls behavior when multiple MCP servers are discovered with the same name. 'disable' disables lower-priority duplicates. 'suffix' appends numeric suffixes to disambiguate."), @@ -2226,6 +2262,7 @@ registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitC registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatQuotaNotificationContribution.ID, ChatQuotaNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(HasByokModelsContribution.ID, HasByokModelsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index fea8be7e5cad4..71e8aa9929230 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -53,7 +53,7 @@ export function getModelHoverContent(model: ILanguageModel): MarkdownString { const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); markdown.appendMarkdown(`**${model.metadata.name}**`); if (model.metadata.id !== model.metadata.version) { - markdown.appendMarkdown(`  _${model.metadata.id}@${model.metadata.version}_ `); + markdown.appendMarkdown(`  _${model.metadata.id}@${model.metadata.version}_ `); } else { markdown.appendMarkdown(`  _${model.metadata.id}_ `); } @@ -490,7 +490,7 @@ class ModelNameColumnRenderer extends ModelsTableColumnRenderer _${entry.model.metadata.id}@${entry.model.metadata.version}_ `); + markdown.appendMarkdown(`  _${entry.model.metadata.id}@${entry.model.metadata.version}_ `); } else { markdown.appendMarkdown(`  _${entry.model.metadata.id}_ `); } diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts new file mode 100644 index 0000000000000..82812e8ca7fb0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { safeIntl } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; +import { getSelectedModelVendor, SELECTED_MODEL_STORAGE_KEY_PREFIX } from '../common/chatSelectedModel.js'; +import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../common/languageModels.js'; +import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js'; + +const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; +const THRESHOLDS = [50, 75, 90, 95]; + +/** + * Core-side workbench contribution that shows chat input notifications for + * quota exhaustion and quota-approaching thresholds. + * + * Listens to `IChatEntitlementService` quota change events and determines + * whether a new threshold has been crossed, then shows the highest-priority + * notification: + * + * 1. **Quota exhausted** — info, auto-dismissed on next message. + * 2. **Quota approaching** — info, auto-dismissed on next message. + * 3. **Rate-limit warning** — info, auto-dismissed on next message. + */ +export class ChatQuotaNotificationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatQuotaNotification'; + + /** Tracks whether the current notification is the quota-exhausted variant. */ + private _showingExhausted = false; + + /** + * Previous percent-used for threshold crossing detection. + * `undefined` means no data has been seen yet — the first value + * establishes a baseline without triggering a notification. + */ + private _prevQuotaPercentUsed: number | undefined; + private _prevAdditionalUsageEnabled: boolean | undefined; + private _prevSessionPercentUsed: number | undefined; + private _prevWeeklyPercentUsed: number | undefined; + + constructor( + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); + this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); + this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); + + // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). + // The chatModelId context key is widget-scoped and may not bubble to the global + // service, so we also listen for storage changes on the persisted model selection key. + const storageListener = this._register(new DisposableStore()); + this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, storageListener)(e => { + if (e.key.startsWith(SELECTED_MODEL_STORAGE_KEY_PREFIX)) { + this._update(); + } + })); + + // Check initial state in case quota is already exhausted at startup + this._update(); + } + + private _getRelevantSnapshot(): IQuotaSnapshot | undefined { + const quotas = this._chatEntitlementService.quotas; + const entitlement = this._chatEntitlementService.entitlement; + if (entitlement === ChatEntitlement.Unknown || entitlement === ChatEntitlement.Free) { + return quotas.chat ?? quotas.premiumChat; + } + return quotas.premiumChat; + } + + private _isQuotaUsedUp(): boolean { + const snapshot = this._getRelevantSnapshot(); + if (!snapshot) { + return false; + } + if (snapshot.unlimited) { + return snapshot.hasQuota === false; + } + return snapshot.percentRemaining <= 0; + } + + private _isUBBEligible(): boolean { + return this._chatEntitlementService.quotas.usageBasedBilling === true; + } + + private _update(): void { + const entitlement = this._chatEntitlementService.entitlement; + const isCopilot = this._isCopilotModelSelected(); + + // Defer new notifications when a BYOK model is selected or the model + // selection hasn't loaded yet — quota only applies to Copilot models. + // Already-shown notifications stay visible. + if (!isCopilot) { + return; + } + + // Skip quota notifications for PRU users — only show for UBB. + const isQuotaNotificationEligible = entitlement === ChatEntitlement.Unknown || this._isUBBEligible(); + + // Priority 1: Quota exhausted or fully used + if (isQuotaNotificationEligible && this._isQuotaUsedUp()) { + const quotas = this._chatEntitlementService.quotas; + const additionalUsageEnabled = quotas.additionalUsageEnabled ?? false; + const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; + this._prevAdditionalUsageEnabled = additionalUsageEnabled; + + if (additionalUsageEnabled) { + // Show overage notification on a live transition to 100%, + // or when overages are enabled while already at 100%. + if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { + this._showOverageActivationNotification(); + } + } else { + this._showExhaustedNotification(); + } + + // Keep the baseline up-to-date so that recovery from exhaustion + // does not trigger a spurious threshold notification. + const exhaustedSnapshot = this._getRelevantSnapshot(); + if (exhaustedSnapshot && !exhaustedSnapshot.unlimited) { + this._prevQuotaPercentUsed = 100 - exhaustedSnapshot.percentRemaining; + } + + return; + } + + // Priority 2: Quota approaching threshold + if (isQuotaNotificationEligible) { + const quotaWarning = this._computeQuotaWarning(); + if (quotaWarning) { + this._showQuotaApproachingWarning(quotaWarning); + return; + } + } + + // Priority 3: Rate-limit warning (session > weekly) + const rateLimitWarning = this._computeRateLimitWarning(); + if (rateLimitWarning) { + this._showRateLimitWarning(rateLimitWarning); + return; + } + + // Nothing new to show — only hide if the exhausted notification is + // active and the quota is no longer exhausted (state-driven). + if (this._showingExhausted && !this._isQuotaUsedUp()) { + this._hideNotification(); + } + } + + // --- Threshold crossing detection ---------------------------------------- + + private _computeQuotaWarning(): { percentUsed: number } | undefined { + const snapshot = this._getRelevantSnapshot(); + if (!snapshot || snapshot.unlimited) { + this._prevQuotaPercentUsed = undefined; + return undefined; + } + const percentUsed = 100 - snapshot.percentRemaining; + const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); + this._prevQuotaPercentUsed = percentUsed; + if (crossed !== undefined) { + return { percentUsed: Math.floor(percentUsed) }; + } + return undefined; + } + + /** + * Returns the highest threshold that was newly crossed, or `undefined`. + */ + private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { + if (previous === undefined) { + return undefined; + } + for (let i = THRESHOLDS.length - 1; i >= 0; i--) { + const threshold = THRESHOLDS[i]; + if (previous < threshold && current >= threshold) { + return threshold; + } + } + return undefined; + } + + // --- Quota exhausted --------------------------------------------------- + + private _showExhaustedNotification(): void { + this._showingExhausted = true; + + const entitlement = this._chatEntitlementService.entitlement; + const quotas = this._chatEntitlementService.quotas; + const hadOverage = (quotas.additionalUsageCount ?? 0) > 0; + + let description: string; + let actions: IChatInputNotification['actions']; + + if (entitlement === ChatEntitlement.Unknown) { + description = localize('quota.exhausted.anonymous', "Sign in to keep going."); + actions = [{ label: localize('signIn', "Sign In"), commandId: 'workbench.action.chat.triggerSetup' }]; + } else if (entitlement === ChatEntitlement.Free) { + description = localize('quota.exhausted.free', "Upgrade to keep going."); + actions = [{ label: localize('upgrade', "Upgrade"), commandId: 'workbench.action.chat.upgradePlan' }]; + } else if (this._isManagedPlan(entitlement)) { + description = localize('quota.exhausted.managed', "Contact your admin to increase your limits."); + actions = []; + } else if (hadOverage) { + description = localize('quota.exhausted.hadOverage', "Increase your budget to keep building."); + actions = [{ label: localize('manageBudget', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } else { + description = localize('quota.exhausted.default', "Manage your budget to keep building."); + actions = [{ label: localize('manageBudget2', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.exhausted.title', "Credit Limit Reached"), + description, + actions, + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Overage notification ----------------------------------------------- + + private _showOverageActivationNotification(): void { + this._showingExhausted = true; + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.overage.title', "Credit Limit Reached"), + description: localize('quota.overage.desc', "Additional budget is now covering extra usage."), + actions: [], + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Quota approaching -------------------------------------------------- + + private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { + this._showingExhausted = false; + + const entitlement = this._chatEntitlementService.entitlement; + const quotas = this._chatEntitlementService.quotas; + + let description: string; + let actions: IChatInputNotification['actions']; + + if (entitlement === ChatEntitlement.Unknown || entitlement === ChatEntitlement.Free) { + description = localize('quota.approaching.free', "Upgrade to continue past the limit."); + actions = [{ label: localize('upgrade2', "Upgrade"), commandId: 'workbench.action.chat.upgradePlan' }]; + } else if (this._isManagedPlan(entitlement)) { + description = localize('quota.approaching.managed', "Contact your admin to increase your limits."); + actions = []; + } else if (quotas.additionalUsageEnabled) { + description = localize('quota.approaching.overageEnabled', "Additional budget is enabled to cover extra usage."); + actions = []; + } else { + description = localize('quota.approaching.default', "Set additional budget to cover extra usage."); + actions = [{ label: localize('manageBudget3', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.approaching.title', "Credits at {0}%", warning.percentUsed), + description, + actions, + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Rate-limit warning ------------------------------------------------- + + private _computeRateLimitWarning(): { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined } | undefined { + const quotas = this._chatEntitlementService.quotas; + + const sessionResult = this._checkRateLimitCrossing(quotas.sessionRateLimit, this._prevSessionPercentUsed); + this._prevSessionPercentUsed = sessionResult.newPrev; + + const weeklyResult = this._checkRateLimitCrossing(quotas.weeklyRateLimit, this._prevWeeklyPercentUsed); + this._prevWeeklyPercentUsed = weeklyResult.newPrev; + + if (sessionResult.warning) { + return { ...sessionResult.warning, type: 'session' }; + } + if (weeklyResult.warning) { + return { ...weeklyResult.warning, type: 'weekly' }; + } + return undefined; + } + + private _checkRateLimitCrossing( + snapshot: IRateLimitSnapshot | undefined, + prevPercentUsed: number | undefined, + ): { newPrev: number | undefined; warning?: { percentUsed: number; resetDate: string | undefined } } { + if (!snapshot || snapshot.unlimited) { + return { newPrev: undefined }; + } + const percentUsed = 100 - snapshot.percentRemaining; + const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); + return { + newPrev: percentUsed, + warning: crossed !== undefined + ? { percentUsed: Math.floor(percentUsed), resetDate: snapshot.resetDate } + : undefined, + }; + } + + private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { + this._showingExhausted = false; + + const message = warning.type === 'session' + ? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed) + : localize('rateLimit.weekly', "You've used {0}% of your weekly rate limit.", warning.percentUsed); + + const description = warning.resetDate + ? localize('rateLimit.resets', "Resets on {0}.", this._formatResetDate(warning.resetDate)) + : undefined; + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message, + description, + actions: [], + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Helpers ------------------------------------------------------------ + + /** + * Returns `true` only when a Copilot model is actively selected. + * Returns `false` if no model is selected yet (widget not initialized) + * or if the selected model is from a non-Copilot vendor (BYOK). + */ + private _isCopilotModelSelected(): boolean { + const vendor = getSelectedModelVendor(this._contextKeyService, this._storageService, this._languageModelsService); + if (!vendor) { + return true; + } + return vendor === COPILOT_VENDOR_ID; + } + + private _isManagedPlan(entitlement: ChatEntitlement): boolean { + return entitlement === ChatEntitlement.Business || entitlement === ChatEntitlement.Enterprise; + } + + private _formatResetDate(isoDate: string): string { + const resetDate = new Date(isoDate); + const now = new Date(); + const includeYear = resetDate.getFullYear() !== now.getFullYear(); + return safeIntl.DateTimeFormat(undefined, includeYear + ? { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' } + : { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' } + ).value.format(resetDate); + } + + private _setNotification(notification: IChatInputNotification): void { + this._chatInputNotificationService.setNotification(notification); + } + + private _hideNotification(): void { + this._showingExhausted = false; + this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 9992894016633..2a139ea9ec694 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -325,7 +325,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: 'workbench.action.chat.triggerSetupForceSignIn', - title: localize2('forceSignIn', "Sign in to use AI features") + title: localize2('forceSignIn', "Sign in to use GitHub Copilot") }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index f0592e7e0f085..f74869f009e00 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -247,7 +247,7 @@ export class ChatSetup { } if (this.context.state.entitlement === ChatEntitlement.Unknown || options?.forceSignInDialog) { - return localize('signIn', "Sign in to use AI Features"); + return localize('signIn', "Sign in to use GitHub Copilot"); } return localize('startUsing', "Start using AI Features"); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 0307acd26e009..8b87991475a0f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -827,16 +827,20 @@ export class ChatStatusDashboard extends DomWidget { quotaCallout.style.display = ''; quotaCallout.className = 'quota-callout info'; calloutIcon.className = `callout-icon ${ThemeIcon.asClassName(Codicon.info)}`; - calloutText.textContent = isUsageBasedBilling - ? localize('quotaAdditionalUsageActive', "Additional budget is configured. Usage will continue until limits reset.") - : localize('quotaBudgetActive', "Premium request budget is configured. Usage will continue until limits reset."); + calloutText.textContent = isEnterpriseUser + ? localize('quotaAdditionalUsageActiveEnterprise', "You've used your included credits. Your organization covers additional usage, so you can keep working.") + : isUsageBasedBilling + ? localize('quotaAdditionalUsageActive', "Additional budget is configured. Usage will continue until limits reset.") + : localize('quotaBudgetActive', "Premium request budget is configured. Usage will continue until limits reset."); } else if (maxUsedPercentage >= 75 && maxUsedPercentage < 100 && additionalUsageEnabled) { quotaCallout.style.display = ''; quotaCallout.className = 'quota-callout info'; calloutIcon.className = `callout-icon ${ThemeIcon.asClassName(Codicon.info)}`; - calloutText.textContent = isUsageBasedBilling - ? localize('quotaAdditionalUsageApproaching', "Once the limit is reached, additional budget will be used.") - : localize('quotaBudgetApproaching', "Once the limit is reached, premium request budget will be used."); + calloutText.textContent = isEnterpriseUser + ? localize('quotaAdditionalUsageApproachingEnterprise', "You're approaching your included credits. Your organization covers additional usage, so there's no interruption.") + : isUsageBasedBilling + ? localize('quotaAdditionalUsageApproaching', "Once the limit is reached, additional budget will be used.") + : localize('quotaBudgetApproaching', "Once the limit is reached, premium request budget will be used."); } else if ((maxUsedPercentage >= 100 || isPooledQuotaExhausted) && !additionalUsageEnabled) { quotaCallout.style.display = ''; quotaCallout.className = 'quota-callout info'; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 1a21aec2e9a80..3a01b337a69ef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -189,7 +189,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu if (chatQuotaExceeded && !completionsQuotaExceeded) { quotaWarning = localize('chatQuotaExceededStatus', "Chat quota reached"); } else if (completionsQuotaExceeded && !chatQuotaExceeded) { - quotaWarning = localize('completionsQuotaExceededStatus', "Inline suggestions quota reached"); + quotaWarning = localize('completionsQuotaExceededStatus', "Inline suggestions limit reached"); } else { quotaWarning = localize('chatAndCompletionsQuotaExceededStatus', "Quota reached"); } diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5ad2fe60724e3..889ae29b56490 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,6 +9,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { getSelectedModelIdentifier } from '../common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -788,26 +789,7 @@ export class ChatTipService extends Disposable implements IChatTipService { return normalizedModelId; }; - const contextKeyModelId = normalize(contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key)); - if (contextKeyModelId) { - return contextKeyModelId; - } - - const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? ChatAgentLocation.Chat; - const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; - const candidateStorageKeys = sessionType - ? [`chat.currentLanguageModel.${location}.${sessionType}`, `chat.currentLanguageModel.${location}`] - : [`chat.currentLanguageModel.${location}`]; - - for (const storageKey of candidateStorageKeys) { - const persistedModelIdentifier = this._storageService.get(storageKey, StorageScope.APPLICATION); - const persistedModelId = normalize(persistedModelIdentifier); - if (persistedModelId) { - return persistedModelId; - } - } - - return ''; + return normalize(getSelectedModelIdentifier(contextKeyService, this._storageService)); } private _isChatLocation(contextKeyService: IContextKeyService): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index e09cf4df42b8d..981d665f87f66 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -15,6 +15,7 @@ import { ChatTreeItem } from '../../chat.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; @@ -43,6 +44,22 @@ import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/mo import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; +// Context key id mirrored from `vs/sessions/common/contextkeys` (`IsPhoneLayoutContext`). +// Inlined as a string because `vs/workbench` must not import from `vs/sessions`. +const SESSIONS_IS_PHONE_LAYOUT_KEY = 'sessionsIsPhoneLayout'; + +/** + * Resolves the effective thinking display mode. On phone layout we always force + * {@link ThinkingDisplayMode.CollapsedPreview} so streaming reasoning takes less + * room and auto-collapses on completion regardless of the user's setting. + */ +export function getEffectiveThinkingDisplayMode(configurationService: IConfigurationService, contextKeyService: IContextKeyService): ThinkingDisplayMode { + if (contextKeyService.getContextKeyValue(SESSIONS_IS_PHONE_LAYOUT_KEY) === true) { + return ThinkingDisplayMode.CollapsedPreview; + } + return configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; +} + function extractTextFromPart(content: IChatThinkingPart): string { const raw = Array.isArray(content.value) ? content.value.join('') : (content.value || ''); return raw.trim(); @@ -87,7 +104,8 @@ export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon lowerToolId.includes('list') || lowerToolId.includes('semantic') || lowerToolId.includes('changes') || - lowerToolId.includes('codebase') + lowerToolId.includes('codebase') || + lowerToolId.includes('checked') ) { return Codicon.search; } @@ -354,6 +372,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen @IHoverService hoverService: IHoverService, @IStorageService private readonly storageService: IStorageService, @IAccessibilityService accessibilityService: IAccessibilityService, + @IContextKeyService contextKeyService: IContextKeyService, ) { const initialText = extractTextFromPart(content); const extractedTitle = extractTitleFromThinkingContent(initialText) @@ -366,7 +385,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.allThinkingParts.push(content); this.showProgressDetails = this.configurationService.getValue(ChatConfiguration.ChatPersistentProgressEnabled) !== false && (this.configurationService.getValue(ChatConfiguration.ProgressBorder) !== true || accessibilityService.isMotionReduced()); - const configuredMode = this.configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; + const configuredMode = getEffectiveThinkingDisplayMode(this.configurationService, contextKeyService); this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 25efe07e1dc62..f9ee44b9315c7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -90,7 +90,7 @@ import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart. import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; -import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; +import { ChatThinkingContentPart, getEffectiveThinkingDisplayMode } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatWorkspaceEditContentPart } from './chatContentParts/chatWorkspaceEditContentPart.js'; @@ -2187,7 +2187,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinkingStyle'); + const style = getEffectiveThinkingDisplayMode(this.configService, this.contextKeyService); if (style === ThinkingDisplayMode.CollapsedPreview) { lastThinking.collapseContent(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 6391bb329b556..ca65a77adc157 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -815,6 +815,33 @@ export class ChatListWidget extends Disposable { this._renderer.updateOptions(options); } + /** + * Update the list/tree color overrides. Re-applies the same fan-out from + * `listBackground`/`listForeground` to all interaction states that was + * originally configured at construction time. + */ + setStyles(styles: IChatListWidgetStyles): void { + this._tree.updateOptions({ + overrideStyles: { + listFocusBackground: styles.listBackground, + listInactiveFocusBackground: styles.listBackground, + listActiveSelectionBackground: styles.listBackground, + listFocusAndSelectionBackground: styles.listBackground, + listInactiveSelectionBackground: styles.listBackground, + listHoverBackground: styles.listBackground, + listBackground: styles.listBackground, + listFocusForeground: styles.listForeground, + listHoverForeground: styles.listForeground, + listInactiveFocusForeground: styles.listForeground, + listInactiveSelectionForeground: styles.listForeground, + listActiveSelectionForeground: styles.listForeground, + listFocusAndSelectionForeground: styles.listForeground, + listActiveSelectionIconForeground: undefined, + listInactiveSelectionIconForeground: undefined, + } + }); + } + /** * Set the visibility of the list. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts b/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts index 2c8a3abf4187a..8c4d2f146cc29 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts @@ -74,9 +74,9 @@ export class ChatEditorOptions extends Disposable { constructor( viewId: string | undefined, - private readonly foreground: string, - private readonly inputEditorBackgroundColor: string, - private readonly resultEditorBackgroundColor: string, + private foreground: string, + private inputEditorBackgroundColor: string, + private resultEditorBackgroundColor: string, @IConfigurationService private readonly configurationService: IConfigurationService, @IThemeService private readonly themeService: IThemeService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService @@ -126,4 +126,11 @@ export class ChatEditorOptions extends Disposable { }; this._onDidChange.fire(); } + + setColors(foreground: string, inputEditorBackgroundColor: string, resultEditorBackgroundColor: string): void { + this.foreground = foreground; + this.inputEditorBackgroundColor = inputEditorBackgroundColor; + this.resultEditorBackgroundColor = resultEditorBackgroundColor; + this.update(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 27155977514c7..a0e8e55ecf2b3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -390,7 +390,7 @@ export class ChatWidget extends Disposable implements IChatWidget { location: ChatAgentLocation | IChatWidgetLocationOptions, viewContext: IChatWidgetViewContext | undefined, private readonly viewOptions: IChatWidgetViewOptions, - private readonly styles: IChatWidgetStyles, + private styles: IChatWidgetStyles, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IDialogService private readonly dialogService: IDialogService, @@ -2031,6 +2031,26 @@ export class ChatWidget extends Disposable implements IChatWidget { this.container.style.setProperty('--vscode-chat-list-background', this.themeService.getColorTheme().getColor(this.styles.listBackground)?.toString() ?? ''); } + /** + * Updates the widget's color styles after construction. Propagates the new + * `listForeground`/`listBackground` to the list widget, pushes the new color + * tokens into `editorOptions` so subscribers (code blocks, result/input editor + * backgrounds, container CSS variables) pick them up via `onDidChange`, and + * refreshes the CSS variables the chat container exposes for stylesheet rules. + */ + setStyles(styles: IChatWidgetStyles): void { + this.styles = styles; + this.listWidget?.setStyles({ + listForeground: styles.listForeground, + listBackground: styles.listBackground, + }); + if (this.container) { + // Updating editorOptions fires onDidChange which triggers onDidStyleChange + // and also propagates the new colors to subscribers like CodeBlockPart. + this.editorOptions.setColors(styles.listForeground, styles.inputEditorBackground, styles.resultEditorBackground); + } + } + setModel(model: IChatModel | undefined): void { if (!this.container || !this.inputPart) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index d5301859fb941..895cf67f9e499 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -66,7 +66,6 @@ import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidg import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { ChatEntitlementContextKeys, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; -import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; interface IChatViewPaneState extends Partial { @@ -128,7 +127,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ICommandService private readonly commandService: ICommandService, @IActivityService private readonly activityService: IActivityService, - @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, @IHostService private readonly hostService: IHostService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -551,13 +549,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { }, editorOverflowWidgetsDomNode, enableImplicitContext: true, - enableWorkingSet: this.workbenchEnvironmentService.isSessionsWindow - ? 'implicit' - : 'explicit', + enableWorkingSet: 'explicit', supportsChangingModes: true, dndContainer: parent, - inputEditorMinLines: this.workbenchEnvironmentService.isSessionsWindow ? 2 : undefined, - isSessionsWindow: this.workbenchEnvironmentService.isSessionsWindow, }, { listForeground: SIDE_BAR_FOREGROUND, diff --git a/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts b/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts new file mode 100644 index 0000000000000..cac84c3c5b87f --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from './actions/chatContextKeys.js'; +import { ILanguageModelsService } from './languageModels.js'; + +/** + * Storage key prefix for persisted model selections. + * Full key format: `chat.currentLanguageModel.{location}[.{sessionType}]` + */ +export const SELECTED_MODEL_STORAGE_KEY_PREFIX = 'chat.currentLanguageModel.'; + +/** + * Builds the storage key used to persist the selected language model for a + * given chat location and optional session type. + * + * Matches the keys written by `chatInputPart.ts` so that other consumers + * can read the persisted model selection without depending on widget internals. + */ +export function getSelectedModelStorageKey(location: string, sessionType?: string): string { + if (sessionType) { + return `${SELECTED_MODEL_STORAGE_KEY_PREFIX}${location}.${sessionType}`; + } + return `${SELECTED_MODEL_STORAGE_KEY_PREFIX}${location}`; +} + +/** + * Resolves the currently selected chat model identifier using a two-step + * strategy: + * + * 1. Read the `chatModelId` context key (set when a chat widget is active). + * 2. Fall back to the persisted storage value written by `chatInputPart`. + * + * Returns the raw model identifier string (may include a vendor prefix like + * `"copilot/gpt-4.1"` from storage, or a short id like `"gpt-4.1"` from + * the context key), or `undefined` if no selection is available. + */ +export function getSelectedModelIdentifier( + contextKeyService: IContextKeyService, + storageService: IStorageService, +): string | undefined { + // Step 1: Context key (live, widget-scoped) + const contextKeyModelId = contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key); + if (contextKeyModelId) { + return contextKeyModelId; + } + + // Step 2: Persisted storage (survives reload, written by chatInputPart) + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? 'panel'; + const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; + const candidateKeys = sessionType + ? [getSelectedModelStorageKey(location, sessionType), getSelectedModelStorageKey(location)] + : [getSelectedModelStorageKey(location)]; + + for (const key of candidateKeys) { + const persisted = storageService.get(key, StorageScope.APPLICATION); + if (persisted) { + return persisted; + } + } + + return undefined; +} + +/** + * Resolves the vendor of the currently selected chat model. + * + * Tries the language model registry first (authoritative when models are + * registered), then falls back to extracting the vendor prefix from the + * persisted model identifier (e.g. `"copilot/gpt-4.1"` → `"copilot"`). + * + * Returns `undefined` if no model selection is available. + */ +export function getSelectedModelVendor( + contextKeyService: IContextKeyService, + storageService: IStorageService, + languageModelsService: ILanguageModelsService, +): string | undefined { + const modelId = getSelectedModelIdentifier(contextKeyService, storageService); + if (!modelId) { + return undefined; + } + + // Try registry lookup first (handles both short and qualified IDs) + const shortId = modelId.includes('/') ? modelId.split('/').pop()! : modelId; + const metadata = languageModelsService.lookupLanguageModel(shortId) + ?? languageModelsService.lookupLanguageModel(modelId); + if (metadata) { + return metadata.vendor; + } + + // Fall back to vendor prefix from the persisted identifier + // (e.g. "copilot/gpt-4.1" or "customendpoint/ANT/claude-sonnet-4-6") + if (modelId.includes('/')) { + return modelId.split('/')[0]; + } + + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 71709dc6a4ce4..a91dae48ddd65 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -154,7 +154,7 @@ export namespace ToolDataSource { if (source.type === 'internal') { return { ordinal: 1, label: localize('builtin', 'Built-In') }; } else if (source.type === 'mcp') { - return { ordinal: 2, label: source.label }; + return { ordinal: 2, label: source.serverLabel || source.label }; } else if (source.type === 'user') { return { ordinal: 0, label: localize('user', 'User Defined') }; } else { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 1abcf628f2aca..7c4e8828a15a9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -20,7 +20,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js'; import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import type { CustomizationRef, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -498,8 +498,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv disposeSession: async () => { }, ...provisionalServiceOverride, } as Partial as IAgentHostUntitledProvisionalSessionService); - const customizationsByType = new Map>(); - const seedActiveClient = (sessionType: string, entry: { customizations: IObservable }): IDisposable => { + const customizationsByType = new Map>(); + const seedActiveClient = (sessionType: string, entry: { customizations: IObservable }): IDisposable => { customizationsByType.set(sessionType, entry.customizations); return toDisposable(() => { if (customizationsByType.get(sessionType) === entry.customizations) { @@ -514,7 +514,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv // `seedActiveClient` directly. This stub just records an empty // entry so the contribution flow completes. const inner = seedActiveClient(sessionType, { - customizations: constObservable([]), + customizations: constObservable([]), }); return { syncProvider: { @@ -4397,8 +4397,8 @@ suite('AgentHostChatContribution', () => { test('dispatches activeClientChanged when a new session is created', async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-a', uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }, ]); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); @@ -4429,7 +4429,7 @@ suite('AgentHostChatContribution', () => { test('re-dispatches activeClientChanged when customizations observable changes', async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', []); + const customizations = observableValue('customizations', []); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -4451,14 +4451,14 @@ suite('AgentHostChatContribution', () => { // Update customizations customizations.set([ - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, + { type: CustomizationType.Plugin, id: 'file:///plugin-b', uri: 'file:///plugin-b', name: 'Plugin B', enabled: true }, ], undefined); const activeClientAction = agentHostService.dispatchedActions.find( d => d.action.type === 'session/activeClientChanged' ); assert.ok(activeClientAction, 'should re-dispatch activeClientChanged on change'); - const ac = activeClientAction!.action as { activeClient: { customizations?: CustomizationRef[] } }; + const ac = activeClientAction!.action as { activeClient: { customizations?: ClientPluginCustomization[] } }; assert.strictEqual(ac.activeClient.customizations?.length, 1); assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-b'); }); @@ -4543,8 +4543,8 @@ suite('AgentHostChatContribution', () => { test('dispatches activeClientChanged when restoring a session where current client customizations are stale', async () => { const { instantiationService, agentHostService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', [ - { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-new', uri: 'file:///plugin-new', name: 'Plugin New', enabled: true }, ]); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionResource = AgentSession.uri('copilot', 'existing-session'); @@ -4562,7 +4562,7 @@ suite('AgentHostChatContribution', () => { activeClient: { clientId: agentHostService.clientId, tools: [], - customizations: [{ uri: 'file:///plugin-old', displayName: 'Plugin Old' }], + customizations: [{ type: CustomizationType.Plugin, id: 'file:///plugin-old', uri: 'file:///plugin-old', name: 'Plugin Old', enabled: true }], }, }); @@ -4584,7 +4584,7 @@ suite('AgentHostChatContribution', () => { const activeClientAction = activeClientActions[0].action; assert.strictEqual(activeClientAction.type, 'session/activeClientChanged'); assert.deepStrictEqual(activeClientAction.activeClient?.customizations, [ - { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + { type: CustomizationType.Plugin, id: 'file:///plugin-new', uri: 'file:///plugin-new', name: 'Plugin New', enabled: true }, ]); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index 7040cc05e8640..1960908da9aee 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -50,13 +50,13 @@ type LocalSyncableFile = { readonly uri: URI; readonly type: PromptsType }; class FakeBundler { readonly received: LocalSyncableFile[][] = []; - constructor(private readonly _result: { uri: string; displayName: string } | undefined = { uri: 'open-plugin://bundle', displayName: 'Open Plugin' }) { } + constructor(private readonly _result: { uri: string; name: string } | undefined = { uri: 'open-plugin://bundle', name: 'Open Plugin' }) { } async bundle(files: readonly LocalSyncableFile[]) { this.received.push([...files]); if (!this._result) { return undefined; } - return { ref: { uri: this._result.uri as never, displayName: this._result.displayName }, paths: [] }; + return { ref: { type: 'plugin' as const, id: this._result.uri, uri: this._result.uri as never, name: this._result.name, enabled: true }, paths: [] }; } } @@ -84,7 +84,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { { uri: builtin.toString(), type: PromptsType.skill }, ]); assert.strictEqual(refs.length, 1); - assert.strictEqual(refs[0].displayName, 'Open Plugin'); + assert.strictEqual(refs[0].name, 'Open Plugin'); }); test('omits disabled built-in skills from the bundle', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts index 41dd9b526bcb8..e98532d6b2d5a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts @@ -75,7 +75,7 @@ suite('SyncedCustomizationBundler', () => { const result = await bundler.bundle([{ uri, type: PromptsType.instructions }]); assert.ok(result, 'should return a result'); assert.ok(result.ref.uri, 'should have a URI'); - assert.strictEqual(result.ref.displayName, 'VS Code Synced Data'); + assert.strictEqual(result.ref.name, 'VS Code Synced Data'); assert.ok(result.ref.nonce, 'should have a nonce'); // Verify the file was written to the in-memory FS @@ -310,6 +310,6 @@ suite('SyncedCustomizationBundler', () => { { uri: uriC, type: PromptsType.prompt }, ]); assert.ok(result); - assert.ok(result.ref.description?.includes('3'), 'description should mention file count'); + assert.ok(result.ref.nonce, 'should produce a nonce reflecting the bundled files'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts new file mode 100644 index 0000000000000..3a6c569f5cd4b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -0,0 +1,619 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment, IQuotaSnapshot, IRateLimitSnapshot } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatQuotaNotificationContribution } from '../../browser/chatQuotaNotification.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../common/languageModels.js'; +import { IChatInputNotification, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; + +// --- Mock IChatEntitlementService ------------------------------------------- + +interface IMockQuotas { + usageBasedBilling?: boolean; + chat?: IQuotaSnapshot; + completions?: IQuotaSnapshot; + premiumChat?: IQuotaSnapshot; + additionalUsageEnabled?: boolean; + additionalUsageCount?: number; + sessionRateLimit?: IRateLimitSnapshot; + weeklyRateLimit?: IRateLimitSnapshot; +} + +function createMockEntitlementService(opts?: { + entitlement?: ChatEntitlement; + quotas?: IMockQuotas; +}) { + const onDidChangeQuotaRemaining = new Emitter(); + const onDidChangeQuotaExceeded = new Emitter(); + const onDidChangeEntitlement = new Emitter(); + + const service: IChatEntitlementService = { + _serviceBrand: undefined, + entitlement: opts?.entitlement ?? ChatEntitlement.Pro, + entitlementObs: observableValue({}, opts?.entitlement ?? ChatEntitlement.Pro), + onDidChangeEntitlement: onDidChangeEntitlement.event, + onDidChangeQuotaExceeded: onDidChangeQuotaExceeded.event, + onDidChangeQuotaRemaining: onDidChangeQuotaRemaining.event, + onDidChangeUsageBasedBilling: Event.None, + quotas: { + usageBasedBilling: opts?.quotas?.usageBasedBilling ?? true, + chat: opts?.quotas?.chat, + completions: opts?.quotas?.completions, + premiumChat: opts?.quotas?.premiumChat, + additionalUsageEnabled: opts?.quotas?.additionalUsageEnabled, + additionalUsageCount: opts?.quotas?.additionalUsageCount, + sessionRateLimit: opts?.quotas?.sessionRateLimit, + weeklyRateLimit: opts?.quotas?.weeklyRateLimit, + }, + organisations: undefined, + isInternal: false, + sku: undefined, + copilotTrackingId: undefined, + previewFeaturesDisabled: false, + clientByokEnabled: false, + hasByokModels: false, + onDidChangeSentiment: Event.None, + sentiment: {} as IChatSentiment, + sentimentObs: observableValue({}, {} as IChatSentiment) as IObservable, + onDidChangeAnonymous: Event.None, + anonymous: false, + anonymousObs: observableValue({}, false), + acceptQuotas() { }, + clearQuotas() { }, + markAnonymousRateLimited() { }, + markSetupCompleted() { }, + setForceHidden() { }, + update() { return Promise.resolve(); }, + }; + + return { service, onDidChangeQuotaRemaining, onDidChangeQuotaExceeded, onDidChangeEntitlement }; +} + +// --- Mock IChatInputNotificationService ------------------------------------ + +function createMockNotificationService() { + let lastNotification: IChatInputNotification | undefined = undefined; + let deleted = false; + let setCount = 0; + + const onDidChange = new Emitter(); + + const service: IChatInputNotificationService = { + _serviceBrand: undefined, + onDidChange: onDidChange.event, + setNotification(notification: IChatInputNotification) { + lastNotification = notification; + deleted = false; + setCount++; + }, + deleteNotification(_id: string) { + deleted = true; + }, + dismissNotification() { }, + getActiveNotification() { return deleted ? undefined : lastNotification; }, + handleMessageSent() { }, + }; + + return { + service, + getNotification(): IChatInputNotification | undefined { return deleted ? undefined : lastNotification; }, + get wasDeleted() { return deleted; }, + get setCount() { return setCount; }, + reset() { lastNotification = undefined; deleted = false; setCount = 0; }, + }; +} + +// --- Helpers --------------------------------------------------------------- + +function makeQuotaSnapshot(percentRemaining: number, opts?: Partial): IQuotaSnapshot { + return { + percentRemaining, + unlimited: false, + ...opts, + }; +} + +function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial): IRateLimitSnapshot { + return { + percentRemaining, + unlimited: false, + resetDate: '2026-06-01T00:00:00Z', + ...opts, + }; +} + +// --- Tests ----------------------------------------------------------------- + +suite('ChatQuotaNotificationContribution', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }) { + const entitlementMock = createMockEntitlementService(entitlementOpts); + const notificationMock = createMockNotificationService(); + const contextKeyService = store.add(new MockContextKeyService()); + const storageService = store.add(new InMemoryStorageService()); + const vendor = modelOpts?.vendor ?? 'copilot'; + // Persist model selection in storage (used by getSelectedModelVendor) + storageService.store('chat.currentLanguageModel.panel', `${vendor}/test-model`, StorageScope.APPLICATION, StorageTarget.USER); + const languageModelsService = { + _serviceBrand: undefined, + onDidChangeLanguageModelVendors: Event.None, + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => ['test-model'], + getVendors: () => [], + lookupLanguageModel: (_id: string): ILanguageModelChatMetadata | undefined => ({ vendor } as ILanguageModelChatMetadata), + lookupLanguageModelByQualifiedName: () => undefined, + } as unknown as ILanguageModelsService; + + // Track disposables for emitters + store.add(entitlementMock.onDidChangeQuotaRemaining); + store.add(entitlementMock.onDidChangeQuotaExceeded); + store.add(entitlementMock.onDidChangeEntitlement); + + const contribution = store.add(new ChatQuotaNotificationContribution( + entitlementMock.service, + notificationMock.service, + contextKeyService as IContextKeyService, + languageModelsService, + storageService, + )); + + return { contribution, entitlementMock, notificationMock, storageService }; + } + + function updateQuotas( + entitlementMock: ReturnType, + quotas: IMockQuotas, + opts?: { entitlement?: ChatEntitlement }, + ) { + const svc: { entitlement: ChatEntitlement; quotas: IMockQuotas } = entitlementMock.service as IChatEntitlementService & { entitlement: ChatEntitlement; quotas: IMockQuotas }; + if (opts?.entitlement !== undefined) { + svc.entitlement = opts.entitlement; + } + svc.quotas = { ...svc.quotas, ...quotas }; + entitlementMock.onDidChangeQuotaRemaining.fire(); + } + + // --- Quota exhausted --------------------------------------------------- + + suite('quota exhausted', () => { + test('shows exhausted notification at startup when premiumChat is at 0%', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('shows exhausted notification for free user via chat snapshot', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('hides exhausted notification when quota recovers', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.wasDeleted); + }); + + test('does not show spurious threshold notification after exhaustion recovery', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, // 40% used baseline + }); + + // Exhaust quota + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(0) }); + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + + notificationMock.reset(); + + // Recover to 55% used — should NOT trigger "Credits at 50%" from stale baseline + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(45) }); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show exhausted for unlimited quota with hasQuota=true', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0, { unlimited: true, hasQuota: true }) }, + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows exhausted for unlimited quota with hasQuota=false', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0, { unlimited: true, hasQuota: false }) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + }); + + // --- Exhausted notification descriptions -------------------------------- + + suite('exhausted notification descriptions', () => { + test('anonymous user gets sign-in action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Unknown, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Sign in to keep going.'); + assert.strictEqual(notificationMock.getNotification()!.actions.length, 1); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.triggerSetup'); + }); + + test('free user gets upgrade action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Upgrade to keep going.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.upgradePlan'); + }); + + test('managed plan user gets admin message', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Business, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Contact your admin to increase your limits.'); + assert.strictEqual(notificationMock.getNotification()!.actions.length, 0); + }); + + test('paid user with overage gets increase budget action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageCount: 5 }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Increase your budget to keep building.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.manageAdditionalSpend'); + }); + + test('paid user without overage gets manage budget action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Manage your budget to keep building.'); + }); + }); + + // --- Quota approaching threshold ---------------------------------------- + + suite('quota approaching threshold', () => { + test('first data arrival stores baseline without notification', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(25) }, // 75% used + }); + + // Initial _update runs in constructor but 75% is baseline, no crossing + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('notifies when crossing 50% threshold', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, // 40% used baseline + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% used + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); + }); + + test('does not re-show the same threshold', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + assert.ok(notificationMock.getNotification()); + + notificationMock.reset(); + + // Fire again at the same level + entitlementMock.onDidChangeQuotaRemaining.fire(); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows higher threshold when usage increases', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(10) }); // 90% + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 90%'); + }); + }); + + // --- PRU users ---------------------------------------------------------- + + suite('PRU users do not see quota notifications', () => { + test('does not show exhausted notification for PRU user', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show approaching notification for PRU user', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(5) }); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + }); + + // --- Overage activation ------------------------------------------------- + + suite('overage activation notification', () => { + test('shows overage notification on live transition to 100%', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(10), additionalUsageEnabled: true }, + }); + + // Transition to 100% + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: true }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + + test('does not show overage notification at startup when already at 100%', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: true }, + }); + + // At startup with overages enabled and already at 0%, no notification + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows standard exhausted on startup at 100% without overages', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: false }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + assert.notStrictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + + test('shows overage notification when overages are enabled while already at 100%', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: false }, + }); + + assert.ok(notificationMock.getNotification()); + + // Enable overages while still at 0% + updateQuotas(entitlementMock, { additionalUsageEnabled: true, premiumChat: makeQuotaSnapshot(0) }); + + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + }); + + // --- Rate-limit warnings ------------------------------------------------ + + suite('rate-limit warnings', () => { + test('shows session rate limit warning on threshold crossing', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(60) }, // baseline + }); + + updateQuotas(entitlementMock, { sessionRateLimit: makeRateLimitSnapshot(25) }); // 75% used + + assert.ok(notificationMock.getNotification()); + assert.ok((notificationMock.getNotification()!.message as string).includes('75%')); + assert.ok((notificationMock.getNotification()!.message as string).includes('session')); + }); + + test('shows weekly rate limit warning on threshold crossing', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, weeklyRateLimit: makeRateLimitSnapshot(60) }, // baseline + }); + + updateQuotas(entitlementMock, { weeklyRateLimit: makeRateLimitSnapshot(10) }); // 90% used + + assert.ok(notificationMock.getNotification()); + assert.ok((notificationMock.getNotification()!.message as string).includes('90%')); + assert.ok((notificationMock.getNotification()!.message as string).includes('weekly')); + }); + + test('first rate limit data stores baseline without notification', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(10) }, // 90% used + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + }); + + // --- Priority ordering -------------------------------------------------- + + suite('priority ordering', () => { + test('exhausted takes priority over approaching threshold', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('approaching threshold takes priority over rate limit', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(60), // 40% — baseline + sessionRateLimit: makeRateLimitSnapshot(60), // 40% — baseline + }, + }); + + updateQuotas(entitlementMock, { + premiumChat: makeQuotaSnapshot(10), // 90% — crosses threshold + sessionRateLimit: makeRateLimitSnapshot(25), // 75% — crosses threshold + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 90%'); + }); + }); + + // --- Approaching notification descriptions ------------------------------ + + suite('approaching notification descriptions', () => { + test('free user gets upgrade action', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { chat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Upgrade to continue past the limit.'); + }); + + test('managed plan user gets admin message', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Enterprise, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Contact your admin to increase your limits.'); + }); + + test('paid user with overages enabled gets budget message', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60), additionalUsageEnabled: true }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is enabled to cover extra usage.'); + }); + + test('paid user without overages gets set budget action', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Set additional budget to cover extra usage.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.manageAdditionalSpend'); + }); + }); + + // --- BYOK model suppression --------------------------------------------- + + suite('BYOK model suppression', () => { + test('defers notifications when BYOK model is selected', () => { + const { notificationMock } = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + { vendor: 'customendpoint' }, + ); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows notification when Copilot model is selected', () => { + const { notificationMock } = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + { vendor: 'copilot' }, + ); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()?.message, 'Credit Limit Reached'); + }); + + test('shows notification when switching from BYOK to Copilot model', () => { + const entitlementMock = createMockEntitlementService({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + const notificationMock = createMockNotificationService(); + const contextKeyService = store.add(new MockContextKeyService()); + const storageService = store.add(new InMemoryStorageService()); + // Start with BYOK model + storageService.store('chat.currentLanguageModel.panel', 'customendpoint/ANT/claude-sonnet-4-6', StorageScope.APPLICATION, StorageTarget.USER); + // Registry returns undefined — vendor detection relies on prefix extraction + const languageModelsService = { + _serviceBrand: undefined, + onDidChangeLanguageModelVendors: Event.None, + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => [], + getVendors: () => [], + lookupLanguageModel: (): ILanguageModelChatMetadata | undefined => undefined, + lookupLanguageModelByQualifiedName: () => undefined, + } as unknown as ILanguageModelsService; + + store.add(entitlementMock.onDidChangeQuotaRemaining); + store.add(entitlementMock.onDidChangeQuotaExceeded); + store.add(entitlementMock.onDidChangeEntitlement); + + store.add(new ChatQuotaNotificationContribution( + entitlementMock.service, + notificationMock.service, + contextKeyService as IContextKeyService, + languageModelsService, + storageService, + )); + + // Initially deferred — BYOK model + assert.strictEqual(notificationMock.getNotification(), undefined); + + // Switch to Copilot model via storage — triggers storage listener + storageService.store('chat.currentLanguageModel.panel', 'copilot/gpt-4.1', StorageScope.APPLICATION, StorageTarget.USER); + + assert.strictEqual(notificationMock.getNotification()?.message, 'Credit Limit Reached'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index dcd10ffed3afb..36db072515018 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -62,6 +62,8 @@ function createEntitlementService(opts: { anonymous: false, onDidChangeAnonymous: Event.None, anonymousObs: observableValue({}, false), + acceptQuotas: () => { }, + clearQuotas: () => { }, markAnonymousRateLimited: () => { }, markSetupCompleted: () => { }, setForceHidden: () => { }, @@ -595,6 +597,40 @@ suite('ChatStatusDashboard', () => { assert.strictEqual(getCalloutText(dashboard.element), 'Additional budget is configured. Usage will continue until limits reset.'); }); + test('Callout: Enterprise — shows org-specific wording when approaching limit with additional usage', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 20, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + additionalUsageEnabled: true, + entitlement: ChatEntitlement.Enterprise, + })); + + assert.strictEqual(getCalloutText(dashboard.element), 'You\'re approaching your included credits. Your organization covers additional usage, so there\'s no interruption.'); + }); + + test('Callout: Business — shows org-specific wording when approaching limit with additional usage', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 20, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + additionalUsageEnabled: true, + entitlement: ChatEntitlement.Business, + })); + + assert.strictEqual(getCalloutText(dashboard.element), 'You\'re approaching your included credits. Your organization covers additional usage, so there\'s no interruption.'); + }); + + test('Callout: Enterprise — shows org-specific wording when quota exhausted with additional usage', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 0, unlimited: false, usageBasedBilling: true }, + completions: { percentRemaining: 90, unlimited: false }, + additionalUsageEnabled: true, + additionalUsageCount: 5, + entitlement: ChatEntitlement.Enterprise, + })); + + assert.strictEqual(getCalloutText(dashboard.element), 'You\'ve used your included credits. Your organization covers additional usage, so you can keep working.'); + }); + // --- LIVE UPDATES --- function createMutableEntitlementService(opts: { diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts index 896e7b70b9345..36adcf7faba1e 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts @@ -7,7 +7,22 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IMcpRemoteServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { convertBareEnvVarsToVsCodeSyntax } from '../../../common/plugins/agentPluginServiceImpl.js'; +import { convertBareEnvVarsToVsCodeSyntax as convertBareEnvVarsToVsCodeSyntaxRaw } from '../../../common/plugins/agentPluginServiceImpl.js'; +import { CustomizationType, type McpServerCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IMcpServerDefinition } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; + +function stubMcpCustomization(): McpServerCustomization { + return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///test', name: 'test' }; +} + +/** + * Wraps the production {@link convertBareEnvVarsToVsCodeSyntaxRaw} so tests + * don't have to spell out the protocol-level `customization` projection on + * every fixture — the env-var conversion never touches it. + */ +function convertBareEnvVarsToVsCodeSyntax(def: Omit) { + return convertBareEnvVarsToVsCodeSyntaxRaw({ ...def, customization: stubMcpCustomization() }); +} suite('convertBareEnvVarsToVsCodeSyntax', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 4ee5b4ec1adeb..86c939d322d24 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -190,6 +190,7 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const name = node.children[0].value as string; + const server = mcpServers.find(s => s.definition.label === name); if (!server) { continue; diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 627c0e43d765f..265071cc6c7ac 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -55,6 +55,25 @@ export const mcpConfigurationSection = 'mcp'; export const mcpDiscoverySection = 'chat.mcp.discovery.enabled'; export const mcpServerSamplingSection = 'chat.mcp.serverSampling'; export const mcpServerCollisionBehaviorSection = 'chat.mcp.collisionBehavior'; +/** + * Configuration key for the enterprise-managed MCP IdP bag. The setting is + * registered with `included: false` so it is hidden from the Settings UI and + * settings.json IntelliSense; it is intended to be delivered through enterprise + * policy (Windows Group Policy / macOS managed preferences / Linux + * `/etc/vscode/policy.json`), with hand-editing of `settings.json` as a + * developer escape hatch. + */ +export const mcpEnterpriseManagedAuthIdpSection = 'mcp.enterpriseManagedAuth.idp'; + +/** + * Shape of the {@link mcpEnterpriseManagedAuthIdpSection} setting. All fields + * are optional so partial configurations (e.g. just the issuer) remain valid. + */ +export interface IMcpEnterpriseManagedAuthIdpConfig { + readonly issuer?: string; + readonly clientId?: string; + readonly clientSecret?: string; +} export const enum McpCollisionBehavior { Disable = 'disable', @@ -280,7 +299,12 @@ export const mcpServerSchema: IJSONSchema = { clientId: { type: 'string', minLength: 1, - markdownDescription: localize('app.mcp.json.oauth.clientId', "The OAuth client ID to use when authenticating with the server. To set the matching client secret securely, use the *Set Client Secret* code lens above this field — secrets are stored in the OS secret store, not in this file.") + markdownDescription: localize('app.mcp.json.oauth.clientId', "The OAuth client ID to use when authenticating with the server. When `enterpriseManaged` is `true`, this is the **resource** authorization server's client ID (the client trusted by the protected resource), not the IdP's. To set the matching client secret, use the *Set Client Secret* code lens above this field — secrets are stored in the OS secret store, not in this file.") + }, + enterpriseManaged: { + type: 'boolean', + default: false, + markdownDescription: localize('app.mcp.json.oauth.enterpriseManaged', "(Preview) When set to `true`, this MCP server authenticates through the SSO issuer configured by `#mcp.enterpriseManagedAuth.idp#` using OAuth Identity Assertion Authorization Grant (ID-JAG). After a one-time sign-in, subsequent enterprise-managed servers connect silently. The IdP issuer and client credentials are read from the `#mcp.enterpriseManagedAuth.idp#` setting; the `clientId` on this server entry is passed to the resource authorization server.") } } }, diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 679bfefeba8a0..a97a32290e221 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -8,11 +8,11 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Iterable } from '../../../../base/common/iterator.js'; import * as json from '../../../../base/common/json.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { mapValues } from '../../../../base/common/objects.js'; -import { autorun, autorunSelfDisposable, derived, disposableObservableValue, IDerivedReader, IObservable, IReader, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, autorunSelfDisposable, derived, derivedDisposable, disposableObservableValue, IDerivedReader, IObservable, IReader, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { createURITransformer } from '../../../../base/common/uriTransformer.js'; @@ -214,6 +214,53 @@ export class McpServerMetadataCache extends Disposable { } } +/** + * Shared across all {@link McpServer}s. Each server `take`s the name it wants + * to base its tool prefix on (announced `serverInfo.title`/`name` when known, + * otherwise the mcp.json key) and gets back a stable, collision-resolved prefix + * observable. When a server's preferred name changes (e.g. after the live + * `serverInfo` arrives), it simply takes again and disposes the previous + * reference; other servers that share the name keep the suffix they were + * already assigned. See #299749. + */ +export class McpPrefixGenerator { + private readonly _buckets = new Map; size: number }>(); + + take(name: string): IReference { + const safeName = name.toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, McpToolName.MaxPrefixLen - McpToolName.Prefix.length - 1); + let bucket = this._buckets.get(safeName); + if (!bucket) { + bucket = { usedIndexes: new Set(), size: 0 }; + this._buckets.set(safeName, bucket); + } + + let index = 1; + while (bucket.usedIndexes.has(index)) { + index++; + } + bucket.usedIndexes.add(index); + bucket.size++; + + // Trim safeName for this output if a multi-digit suffix would push us past + // MaxPrefixLen. The bucket is keyed on the un-trimmed safeName so collisions + // are still detected consistently across indexes. + const suffix = (index === 1 ? '' : String(index)) + '_'; + const maxNameLen = McpToolName.MaxPrefixLen - McpToolName.Prefix.length - suffix.length; + const prefix = McpToolName.Prefix + safeName.slice(0, maxNameLen) + suffix; + + return { + object: prefix, + dispose: () => { + bucket!.usedIndexes.delete(index); + bucket!.size--; + if (bucket!.size === 0) { + this._buckets.delete(safeName); + } + }, + }; + } +} + type ValidatedMcpTool = MCP.Tool & { _icons: StoredMcpIcons; @@ -433,7 +480,7 @@ export class McpServer extends Disposable implements IMcpServer { explicitRoots: URI[] | undefined, private readonly _requiresExtensionActivation: boolean | undefined, private readonly _primitiveCache: McpServerMetadataCache, - toolPrefix: string, + prefixGenerator: McpPrefixGenerator, enablementModel: IEnablementModel, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IWorkspaceContextService workspacesService: IWorkspaceContextService, @@ -516,6 +563,22 @@ export class McpServer extends Disposable implements IMcpServer { return def && def.cacheNonce !== this._tools.fromCache?.nonce ? def.staticMetadata : undefined; }); + this._serverMetadata = new CachedPrimitive( + this.definition.id, + this._primitiveCache, + staticMetadata.map(m => m ? this._toStoredMetadata(m?.serverInfo, m?.instructions) : undefined), + (entry) => ({ serverName: entry.serverName, serverInstructions: entry.serverInstructions, serverIcons: entry.serverIcons }), + (entry) => ({ serverName: entry?.serverName, serverInstructions: entry?.serverInstructions, icons: McpIcons.fromStored(entry?.serverIcons) }), + undefined, + ); + + // Form the tool prefix from the server-announced name when known so that + // registry-style mcp.json keys like `io.github.upstash/context7` don't end + // up in `mcp_io_github_ups_*` truncated names. See #299749. + const preferredName = derived(reader => this._serverMetadata.value.read(reader)?.serverName || this.definition.label); + const prefixRef = derivedDisposable(reader => prefixGenerator.take(preferredName.read(reader))); + const toolPrefix = prefixRef.map(ref => ref.object); + // 3. Publish tools this._tools = new CachedPrimitive( this.definition.id, @@ -527,7 +590,7 @@ export class McpServer extends Disposable implements IMcpServer { }) .map((o, reader) => o?.promiseResult.read(reader)?.data), (entry) => entry.tools, - (entry) => entry.map(def => this._instantiationService.createInstance(McpTool, this, toolPrefix, def)).sort((a, b) => a.compare(b)), + (entry, reader) => entry.map(def => this._instantiationService.createInstance(McpTool, this, toolPrefix.read(reader), def)).sort((a, b) => a.compare(b)), [], ); @@ -541,15 +604,6 @@ export class McpServer extends Disposable implements IMcpServer { [], ); - this._serverMetadata = new CachedPrimitive( - this.definition.id, - this._primitiveCache, - staticMetadata.map(m => m ? this._toStoredMetadata(m?.serverInfo, m?.instructions) : undefined), - (entry) => ({ serverName: entry.serverName, serverInstructions: entry.serverInstructions, serverIcons: entry.serverIcons }), - (entry) => ({ serverName: entry?.serverName, serverInstructions: entry?.serverInstructions, icons: McpIcons.fromStored(entry?.serverIcons) }), - undefined, - ); - this._capabilities = new CachedPrimitive( this.definition.id, this._primitiveCache, @@ -558,6 +612,10 @@ export class McpServer extends Disposable implements IMcpServer { (entry) => entry, undefined, ); + + // Hold the prefix for the lifetime of the server so its tool name stays + // stable even when no one is currently observing the tools list. + prefixRef.recomputeInitiallyAndOnChange(this._store); } public readDefinitions(): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> { diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 4fa9f3430ab26..4c28260a133a8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -16,11 +16,11 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm import { ContributionEnablementState, EnablementModel, IEnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from './mcpConfiguration.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; -import { McpServer, McpServerMetadataCache } from './mcpServer.js'; -import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, McpToolName, UserInteractionRequiredError } from './mcpTypes.js'; +import { McpPrefixGenerator, McpServer, McpServerMetadataCache } from './mcpServer.js'; +import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, UserInteractionRequiredError } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; -type IMcpServerRec = { object: IMcpServer; toolPrefix: string }; +type IMcpServerRec = { object: IMcpServer }; export class McpService extends Disposable implements IMcpService { @@ -30,6 +30,8 @@ export class McpService extends Disposable implements IMcpService { private readonly _servers = observableValue(this, []); public readonly servers: IObservable = this._servers.map(servers => servers.map(s => s.object)); + private readonly _prefixGenerator = new McpPrefixGenerator(); + public get lazyCollectionState() { return this._mcpRegistry.lazyCollectionState; } public readonly enablementModel: McpCollisionEnablementModel; @@ -174,11 +176,9 @@ export class McpService extends Disposable implements IMcpService { } public updateCollectedServers() { - const prefixGenerator = new McpPrefixGenerator(); const definitions = this._mcpRegistry.collections.get().flatMap(collectionDefinition => collectionDefinition.serverDefinitions.get().map(serverDefinition => { - const toolPrefix = prefixGenerator.generate(serverDefinition.label); - return { serverDefinition, collectionDefinition, toolPrefix }; + return { serverDefinition, collectionDefinition }; }) ); @@ -198,7 +198,7 @@ export class McpService extends Disposable implements IMcpService { // Transfer over any servers that are still valid. for (const server of currentServers) { - const match = definitions.find(d => defsEqual(server.object, d) && server.toolPrefix === d.toolPrefix); + const match = definitions.find(d => defsEqual(server.object, d)); if (match) { pushMatch(match, server); } else { @@ -215,11 +215,11 @@ export class McpService extends Disposable implements IMcpService { def.serverDefinition.roots, !!def.collectionDefinition.lazy, def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache, - def.toolPrefix, + this._prefixGenerator, this.enablementModel, ); - nextServers.push({ object, toolPrefix: def.toolPrefix }); + nextServers.push({ object }); } transaction(tx => { @@ -237,21 +237,6 @@ function defsEqual(server: IMcpServer, def: { serverDefinition: McpServerDefinit return server.collection.id === def.collectionDefinition.id && server.definition.id === def.serverDefinition.id; } -// Helper class for generating unique MCP tool prefixes -class McpPrefixGenerator { - private readonly seenPrefixes = new Set(); - - generate(label: string): string { - const baseToolPrefix = McpToolName.Prefix + label.toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, McpToolName.MaxPrefixLen - McpToolName.Prefix.length - 1); - let toolPrefix = baseToolPrefix + '_'; - for (let i = 2; this.seenPrefixes.has(toolPrefix); i++) { - toolPrefix = baseToolPrefix + i + '_'; - } - this.seenPrefixes.add(toolPrefix); - return toolPrefix; - } -} - /** * Wraps an {@link EnablementModel} with collision-aware defaults and * mutual-exclusion logic for MCP servers with the same label. diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 5943ebd125005..b0ed36f4c2e8d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -545,6 +545,13 @@ export interface McpServerTransportHTTPAuthentication { export interface McpServerTransportHTTPOAuth { readonly clientId?: string; + /** + * (Preview) When true, the MCP server uses enterprise-managed authentication via the configured + * SSO issuer (see `mcp.enterpriseManagedAuth.idp`). Tokens are obtained through OAuth Identity + * Assertion Authorization Grant (ID-JAG) so that, after a one-time sign-in, subsequent enterprise-managed + * servers connect silently. + */ + readonly enterpriseManaged?: boolean; } /** diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts new file mode 100644 index 0000000000000..09063c7ebad79 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { IReference } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { McpPrefixGenerator } from '../../common/mcpServer.js'; +import { McpToolName } from '../../common/mcpTypes.js'; + +suite('McpPrefixGenerator', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('basic prefix uses mcp_ + safe lower-case name', () => { + const gen = new McpPrefixGenerator(); + const ref = gen.take('Context7'); + assert.strictEqual(ref.object, `${McpToolName.Prefix}context7_`); + ref.dispose(); + }); + + test('long registry-style names are clamped to MaxPrefixLen', () => { + const gen = new McpPrefixGenerator(); + // Repro for #299749: a long registry-style key should at least produce a + // valid (length-bounded) prefix. The actual readability fix comes from + // callers taking with the announced server title instead of this key. + const ref = gen.take('io.github.upstash/context7'); + assert.ok(ref.object.startsWith(McpToolName.Prefix)); + assert.ok(ref.object.length <= McpToolName.MaxPrefixLen); + ref.dispose(); + }); + + test('collisions add numeric suffixes', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + const c = gen.take('foo'); + assert.strictEqual(a.object, `${McpToolName.Prefix}foo_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}foo2_`); + assert.strictEqual(c.object, `${McpToolName.Prefix}foo3_`); + a.dispose(); + b.dispose(); + c.dispose(); + }); + + test('dispose releases the slot and the next take reuses the lowest index', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + const c = gen.take('foo'); + assert.strictEqual(b.object, `${McpToolName.Prefix}foo2_`); + + b.dispose(); + const d = gen.take('foo'); + assert.strictEqual(d.object, `${McpToolName.Prefix}foo2_`, 'reuses freed slot'); + + a.dispose(); + c.dispose(); + d.dispose(); + }); + + test('disposing only consumer cleans up the bucket so the next take starts at index 1', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + a.dispose(); + b.dispose(); + + const c = gen.take('foo'); + assert.strictEqual(c.object, `${McpToolName.Prefix}foo_`); + c.dispose(); + }); + + test('different names live in independent buckets', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('bar'); + assert.strictEqual(a.object, `${McpToolName.Prefix}foo_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}bar_`); + a.dispose(); + b.dispose(); + }); + + test('names are sanitized and lower-cased the same way before bucketing', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('My Server'); + const b = gen.take('my server'); + const c = gen.take('my/server'); + // All collapse to the same safe name `my_server`, so they collide. + assert.strictEqual(a.object, `${McpToolName.Prefix}my_server_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}my_server2_`); + assert.strictEqual(c.object, `${McpToolName.Prefix}my_server3_`); + a.dispose(); + b.dispose(); + c.dispose(); + }); + + test('prefix length never exceeds MaxPrefixLen, including for multi-digit collision suffixes', () => { + const gen = new McpPrefixGenerator(); + // Pick a name that, once sanitized, sits right at the per-bucket length cap + // so that adding a numeric suffix would otherwise blow past MaxPrefixLen. + const longName = 'a'.repeat(McpToolName.MaxPrefixLen); + const refs: IReference[] = []; + for (let i = 0; i < 12; i++) { + refs.push(gen.take(longName)); + } + for (const ref of refs) { + assert.ok(ref.object.startsWith(McpToolName.Prefix)); + assert.ok(ref.object.endsWith('_')); + assert.ok(ref.object.length <= McpToolName.MaxPrefixLen, `prefix ${ref.object} (length ${ref.object.length}) exceeds MaxPrefixLen ${McpToolName.MaxPrefixLen}`); + } + // All 12 must be unique so they remain distinguishable. + assert.strictEqual(new Set(refs.map(r => r.object)).size, refs.length); + for (const ref of refs) { + ref.dispose(); + } + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index dd43231744e0d..086f87c5a0d29 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -166,7 +166,16 @@ suite('TerminalSandboxService - network domains', () => { readonlyPaths: ['c:\\tools\\node'], readwritePaths: [], }; - environment = ['PATH=c:\\tools\\node;c:\\windows\\system32', 'PSHOME=c:\\program files\\powershell\\7']; + environment = [ + 'SystemRoot=c:\\windows', + 'PATH=c:\\tools\\node;c:\\windows\\system32', + 'ComSpec=c:\\windows\\system32\\cmd.exe', + 'PATHEXT=.COM;.EXE;.BAT;.CMD;.PS1', + 'PSModulePath=c:\\users\\test\\documents\\powershell\\modules;c:\\program files\\powershell\\modules', + 'USERPROFILE=c:\\users\\test', + 'APPDATA=c:\\users\\test\\appdata\\roaming', + 'PSHOME=c:\\program files\\powershell\\7' + ]; checkSandboxDependencies(): Promise { this.callCount++; @@ -1221,7 +1230,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); const configPath = await sandboxService.getSandboxConfigPath(); - const wrapped = await sandboxService.wrapCommand('echo test', false, 'pwsh.exe', URI.file('/c:/workspace-one')); + const wrapped = await sandboxService.wrapCommand('echo test', false, 'c:\\program files\\powershell\\7\\pwsh.exe', URI.file('/c:/workspace-one')); ok(configPath, 'Config path should be defined for remote Windows'); const configContent = createdFiles.get(configPath); @@ -1232,15 +1241,22 @@ suite('TerminalSandboxService - network domains', () => { ok(wrapped.command.includes('node_modules\\@microsoft\\mxc-sdk\\bin\\arm64\\wxc-exec.exe'), `Wrapped command should use the MXC Windows executable. Actual: ${wrapped.command}`); ok(wrapped.command.includes(configPath), `Wrapped command should pass the MXC config path. Actual: ${wrapped.command}`); strictEqual(config.version, '0.4.0-alpha'); - strictEqual(config.containment, 'process'); - strictEqual(config.process.commandLine, 'echo test'); + strictEqual(config.containment, 'processcontainer'); + strictEqual(config.process.commandLine, '"c:\\program files\\powershell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo test"'); strictEqual(config.process.cwd, 'c:\\workspace-one'); + ok(config.process.env.includes('SystemRoot=c:\\windows'), 'SystemRoot should be injected into the MXC process env'); ok(config.process.env.includes('PATH=c:\\tools\\node;c:\\windows\\system32'), 'PATH should be injected into the MXC process env'); + ok(config.process.env.includes('ComSpec=c:\\windows\\system32\\cmd.exe'), 'ComSpec should be injected into the MXC process env'); + ok(config.process.env.includes('PATHEXT=.COM;.EXE;.BAT;.CMD;.PS1'), 'PATHEXT should be injected into the MXC process env'); + ok(config.process.env.includes('PSModulePath=c:\\users\\test\\documents\\powershell\\modules;c:\\program files\\powershell\\modules'), 'PSModulePath should be injected into the MXC process env'); + ok(config.process.env.includes('USERPROFILE=c:\\users\\test'), 'USERPROFILE should be injected into the MXC process env'); + ok(config.process.env.includes('APPDATA=c:\\users\\test\\appdata\\roaming'), 'APPDATA should be injected into the MXC process env'); ok(config.process.env.includes('PSHOME=c:\\program files\\powershell\\7'), 'PSHOME should be injected into the MXC process env'); ok(config.filesystem.readwritePaths.includes('c:\\workspace-one'), 'Workspace folder should be writable in the MXC config'); ok(config.filesystem.readwritePaths.some((path: string) => path.includes('tmp_vscode_7')), 'Sandbox temp dir should be writable in the MXC config'); ok(config.filesystem.readonlyPaths.includes('c:\\app'), 'VS Code app root should be readable in the MXC config'); ok(config.filesystem.readonlyPaths.includes('c:\\tools\\node'), 'MXC available tools policy should add tool paths to readonly paths'); + ok(config.filesystem.readonlyPaths.includes('c:\\program files\\powershell\\7'), 'Resolved PowerShell executable directory should be readable in the MXC config'); ok(!config.filesystem.deniedPaths.includes('c:\\Users\\test'), 'User home should not be denied by default in the MXC config on Windows'); }); diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 8b72fd6a0e939..ad276fc2c496f 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -482,7 +482,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi title.textContent = localize('onboarding.signIn.heroTitle', "Welcome to VS Code"); const subtitle = append(contentMain, $('p.onboarding-a-signin-subtitle')); - subtitle.textContent = localize('onboarding.signIn.heroSubtitle', "Sign in to continue with AI-powered development."); + subtitle.textContent = localize('onboarding.signIn.heroSubtitle', "Sign in to use GitHub Copilot."); const actions = append(contentMain, $('.onboarding-a-signin-actions')); @@ -1133,12 +1133,12 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Group 1: Chat modes — Plan / Agent const chatGroup = append(features, $('.onboarding-a-sessions-group')); const chatLabel = append(chatGroup, $('div.onboarding-a-sessions-group-label')); - chatLabel.textContent = localize('onboarding.sessions.group.chat', "Choose Your Agent"); + chatLabel.textContent = localize('onboarding.sessions.group.chat', "Agents made for the task"); const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(chatGrid, Codicon.listOrdered, localize('onboarding.sessions.planMode', "Plan"), - localize('onboarding.sessions.planMode.desc', "Produce a structured implementation plan before any code changes, then hand it off to an implementation agent to execute.")); + localize('onboarding.sessions.planMode.desc', "Produce a structured implementation plan before any code changes, then hand it off to an agent to execute.")); this._createFeatureCard(chatGrid, Codicon.commentDiscussion, localize('onboarding.sessions.agentMode', "Agent"), @@ -1147,7 +1147,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Group 2: ways to run and customize agents beyond the default Chat experience const moreGroup = append(features, $('.onboarding-a-sessions-group')); const moreLabel = append(moreGroup, $('div.onboarding-a-sessions-group-label')); - moreLabel.textContent = localize('onboarding.sessions.group.more', "Agents That Work Your Way"); + moreLabel.textContent = localize('onboarding.sessions.group.more', "Agents that work your way"); const moreGrid = append(moreGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(moreGrid, Codicon.rocket, @@ -1164,10 +1164,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } private _createFeatureCard(parent: HTMLElement, icon: ThemeIcon, title: string, description?: string): HTMLElement { - const card = this._registerStepFocusable(append(parent, $('div.onboarding-a-feature-card'))); - card.setAttribute('tabindex', '0'); - card.setAttribute('role', 'group'); - card.setAttribute('aria-label', title); + const card = append(parent, $('div.onboarding-a-feature-card')); const iconCol = append(card, $('div.onboarding-a-feature-icon')); iconCol.appendChild(renderIcon(icon)); const textCol = append(card, $('div.onboarding-a-feature-text')); diff --git a/src/vs/workbench/services/authentication/browser/authenticationService.ts b/src/vs/workbench/services/authentication/browser/authenticationService.ts index e57e08edea106..55fd7e1659276 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationService.ts @@ -381,6 +381,25 @@ export class AuthenticationService extends Disposable implements IAuthentication return undefined; } + async createOrGetXaaProvider(issuer: URI): Promise { + const providerId = `xaa:${issuer.toString(true)}`; + if (this._authenticationProviders.has(providerId)) { + return providerId; + } + const delegate = this._delegates.find(d => !!d.createXaa); + if (!delegate) { + this._logService.error('No authentication provider host delegate supports XAA'); + return undefined; + } + const created = await delegate.createXaa!(issuer); + if (this._authenticationProviders.has(created)) { + this._logService.debug(`Created XAA authentication provider: ${created}`); + return created; + } + this._logService.error(`Failed to create XAA authentication provider for issuer: ${issuer.toString(true)}`); + return undefined; + } + registerAuthenticationProviderHostDelegate(delegate: IAuthenticationProviderHostDelegate): IDisposable { this._delegates.push(delegate); this._delegates.sort((a, b) => b.priority - a.priority); diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index 2c2c87ccacc48..e918912d9486b 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -58,6 +58,13 @@ export interface IAuthenticationCreateSessionOptions { * (RFC 8707 resource indicator). */ resource?: string; + /** + * The audience for the requested access token. Primarily used for OAuth Identity Assertion + * Authorization Grant (ID-JAG, defined in `draft-ietf-oauth-identity-assertion-authz-grant` using RFC 8693 token-exchange semantics) flows where the audience identifies the authorization server of the resource that + * will redeem the assertion (typically the resource's authorization server URL). Providers that do not understand audience-bound tokens should + * ignore this option. + */ + audience?: string; /** * Allows the authentication provider to take in additional parameters. * It is up to the provider to define what these parameters are and handle them. @@ -126,6 +133,13 @@ export interface IAuthenticationGetSessionsOptions { * (RFC 8707 resource indicator). */ resource?: string; + /** + * The audience for the requested access token. Primarily used for OAuth Identity Assertion + * Authorization Grant (ID-JAG, defined in `draft-ietf-oauth-identity-assertion-authz-grant` using RFC 8693 token-exchange semantics) flows where the audience identifies the authorization server of the resource that + * will redeem the assertion (typically the resource's authorization server URL). Providers that do not understand audience-bound tokens should + * ignore this option. + */ + audience?: string; /** * Allows the authentication provider to take in additional parameters. * It is up to the provider to define what these parameters are and handle them. @@ -153,6 +167,11 @@ export interface IAuthenticationProviderHostDelegate { /** Priority for this delegate, delegates are tested in descending priority order */ readonly priority: number; create(authorizationServer: URI, serverMetadata: IAuthorizationServerMetadata, resource: IAuthorizationProtectedResourceMetadata | undefined, clientId?: string, clientSecret?: string): Promise; + /** + * Creates an XAA (enterprise-managed, ID-JAG) authentication provider for the given SSO issuer. + * The returned string is the provider id. + */ + createXaa?(issuer: URI): Promise; } export const IAuthenticationService = createDecorator('IAuthenticationService'); @@ -282,6 +301,15 @@ export interface IAuthenticationService { * @param serverMetadata The metadata for the server that is being authenticated against */ createDynamicAuthenticationProvider(authorizationServer: URI, serverMetadata: IAuthorizationServerMetadata, resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, clientId?: string, clientSecret?: string): Promise; + + /** + * Gets or creates a built-in XAA (enterprise-managed, ID-JAG) authentication provider for the given + * SSO issuer. Subsequent calls with the same issuer return the existing provider. The returned id + * can be used with {@link getSessions}/{@link createSession} just like any other provider. + * + * @param issuer The OAuth/OIDC issuer URL (typically read from `mcp.enterpriseManagedAuth.idp`). + */ + createOrGetXaaProvider(issuer: URI): Promise; } export function isAuthenticationSession(thing: unknown): thing is AuthenticationSession { @@ -391,6 +419,13 @@ export interface IAuthenticationProviderSessionOptions { * (RFC 8707 resource indicator). */ resource?: string; + /** + * The audience for the requested access token. Primarily used for OAuth Identity Assertion + * Authorization Grant (ID-JAG, defined in `draft-ietf-oauth-identity-assertion-authz-grant` using RFC 8693 token-exchange semantics) flows where the audience identifies the authorization server of the resource that + * will redeem the assertion (typically the resource's authorization server URL). Providers that do not understand audience-bound tokens should + * ignore this option. + */ + audience?: string; /** * Allows the authentication provider to take in additional parameters. * It is up to the provider to define what these parameters are and handle them. diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts index 2528c9f41ae17..9dc3c62ae1393 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts @@ -342,6 +342,7 @@ export class TestAuthenticationService extends BaseTestService implements IAuthe unregisterAuthenticationProvider(): void { } registerAuthenticationProviderHostDelegate(): IDisposable { return { dispose: () => { } }; } createDynamicAuthenticationProvider(): Promise { return Promise.resolve(undefined); } + createOrGetXaaProvider(): Promise { return Promise.resolve(undefined); } async requestNewSession(): Promise { return createSession(); } async getSession(): Promise { return createSession(); } getOrActivateProviderIdForServer(): Promise { return Promise.resolve(undefined); } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index f9110d37144df..21d7f8667dd1e 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -193,6 +193,13 @@ export interface IChatEntitlementService { readonly anonymous: boolean; readonly anonymousObs: IObservable; + acceptQuotas(quotas: IQuotas): void; + + /** + * Clear all quota state. + */ + clearQuotas(): void; + markAnonymousRateLimited(): void; /** @@ -567,7 +574,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._onDidChangeQuotaExceeded.fire(); } - if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { + const sessionRateLimitChanged = oldQuota.sessionRateLimit?.percentRemaining !== quotas.sessionRateLimit?.percentRemaining; + const weeklyRateLimitChanged = oldQuota.weeklyRateLimit?.percentRemaining !== quotas.weeklyRateLimit?.percentRemaining; + + if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || sessionRateLimitChanged || weeklyRateLimitChanged || oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { this._onDidChangeQuotaRemaining.fire(); } @@ -753,6 +763,12 @@ export interface IQuotaSnapshot { readonly quotaRemaining?: number; } +export interface IRateLimitSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; +} + interface IQuotas { readonly resetDate?: string; readonly resetDateHasTime?: boolean; @@ -765,6 +781,9 @@ interface IQuotas { readonly premiumChat?: IQuotaSnapshot; readonly additionalUsageEnabled?: boolean; readonly additionalUsageCount?: number; + + readonly sessionRateLimit?: IRateLimitSnapshot; + readonly weeklyRateLimit?: IRateLimitSnapshot; } export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index b8557cefb0ec8..70ffa107d85ec 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -815,6 +815,8 @@ export class TestChatEntitlementService implements IChatEntitlementService { onDidChangeAnonymous = Event.None; readonly anonymousObs = observableValue({}, false); + acceptQuotas(): void { } + clearQuotas(): void { } markAnonymousRateLimited(): void { } markSetupCompleted(): void { } setForceHidden(_hidden: boolean): void { } diff --git a/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts b/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts new file mode 100644 index 0000000000000..5ac42ac018fff --- /dev/null +++ b/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/316625 + + export interface AuthenticationProviderSessionOptions { + /** + * (Preview) The audience for the requested access token. Primarily used for OAuth Identity + * Assertion Authorization Grant (ID-JAG; draft-ietf-oauth-identity-assertion-authz-grant) + * flows where the audience is the authorization server URL of the resource that will redeem + * the assertion. Combine with {@link resource} which carries the resource indicator (RFC 8707). + * Providers that do not understand audience-bound tokens should ignore this option. + */ + audience?: string; + } + + export interface AuthenticationGetSessionOptions { + /** + * (Preview) The audience for the requested access token. Primarily used for OAuth Identity + * Assertion Authorization Grant (ID-JAG) flows where the audience is the authorization server + * URL of the resource that will redeem the assertion. Combine with {@link resource} (RFC 8707). + */ + audience?: string; + } +} diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index e5a1abb3ccdd2..9cbc18c074aaa 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -468,4 +468,55 @@ declare module 'vscode' { */ readonly fullReferenceName?: string; } + + // #region Quota Sync + + /** + * A snapshot of quota usage for a single category (chat, completions, premium chat). + */ + export interface ChatQuotaSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly hasQuota?: boolean; + readonly resetAt?: number; + readonly usageBasedBilling?: boolean; + readonly entitlement?: number; + readonly quotaRemaining?: number; + } + + /** + * A snapshot of rate limit usage for a category (session or weekly). + */ + export interface ChatRateLimitSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; + } + + /** + * Quota snapshot data covering all categories. + * Accepted by {@link chat.updateQuotas} for extension-to-core sync. + */ + export interface ChatQuotaSnapshots { + readonly resetDate?: string; + readonly resetDateHasTime?: boolean; + readonly usageBasedBilling?: boolean; + readonly canUpgradePlan?: boolean; + readonly chat?: ChatQuotaSnapshot; + readonly completions?: ChatQuotaSnapshot; + readonly premiumChat?: ChatQuotaSnapshot; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly sessionRateLimit?: ChatRateLimitSnapshot; + readonly weeklyRateLimit?: ChatRateLimitSnapshot; + } + + export namespace chat { + /** + * Push quota snapshot data from the extension to the core workbench. + */ + export function updateQuotas(quotas: ChatQuotaSnapshots): void; + } + + // #endregion }