diff --git a/.github/instructions/css-best-practices.instructions.md b/.github/instructions/css-best-practices.instructions.md index f83a0ceb4b0931..2b0ceec2eea3ba 100644 --- a/.github/instructions/css-best-practices.instructions.md +++ b/.github/instructions/css-best-practices.instructions.md @@ -8,4 +8,4 @@ applyTo: "**/*.css" ## Selectors - Avoid `:has()` selectors. Because their result depends on descendant state, DOM mutations can invalidate styles on ancestors and cause expensive style recalculation, especially when selectors are broadly scoped. Instead, represent the state explicitly with a class or data attribute on the smallest container you own, and scope selectors to that marker. Add and remove the marker together with the state it represents. -- Never match the `class` attribute by substring (`[class*="…"]`, `[class^="…"]`, `[class$="…"]`). A single such selector anywhere in the workbench stylesheet defeats Blink's per-class invalidation: every `classList` change then forces a style recalculation for that element, even when no rule references the class that changed. Measured on a 3.7k-node workbench, the ten `[class*="monaco-decoration-itemColor"]` selectors in the Modern UI tab styles alone made a full style recalculation 2.4x slower. When a class carries a generated suffix, have the code that applies it also set a stable marker class (see `DECORATION_LABEL_COLOR_CLASS`) and match that instead. +- Never add a new match on the `class` attribute by substring (`[class*="…"]`, `[class^="…"]`, `[class$="…"]`). A single such selector anywhere in the workbench stylesheet defeats Blink's per-class invalidation: every `classList` change then forces a style recalculation for that element, even when no rule references the class that changed. Measured on a 3.7k-node workbench, the ten `[class*="monaco-decoration-itemColor"]` selectors in the Modern UI tab styles alone made a full style recalculation 2.4x slower. When a class carries a generated suffix, have the code that applies it also set a stable marker class (see `DECORATION_LABEL_COLOR_CLASS`) and match that instead. Existing `codicon-*` substring selectors are a grandfathered compatibility contract and must only be changed in a dedicated, separately validated codicon migration. diff --git a/.github/skills/agent-host-e2e-tests/SKILL.md b/.github/skills/agent-host-e2e-tests/SKILL.md index b4b31db0f8eea8..8e221cc2e18af2 100644 --- a/.github/skills/agent-host-e2e-tests/SKILL.md +++ b/.github/skills/agent-host-e2e-tests/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-host-e2e-tests -description: Use when writing, recording, updating, or troubleshooting the agent host end-to-end tests under src/vs/platform/agentHost/test/node/e2e (black-box tests that drive the whole agent host over the AHP protocol, using a CapiReplayProxy record/replay system for Claude/Copilot/Codex). Covers adding a cross-provider test, re-recording fixtures after an SDK bump, gating non-deterministic or platform-specific tests, and diagnosing replay cache misses. +description: Use when writing, recording, updating, validating, or troubleshooting the agent host end-to-end tests under src/vs/platform/agentHost/test/node/e2e (black-box tests that drive the whole agent host over the AHP protocol, using a CapiReplayProxy record/replay system for Claude/Copilot/Codex). Covers adding a cross-provider test, re-recording fixtures after an SDK bump, cross-platform Azure validation, gating non-deterministic or platform-specific tests, and diagnosing replay cache misses. --- # Agent host end-to-end tests @@ -27,6 +27,8 @@ It documents the mental model, the fixture format, every config flag, and a symp 2. Keep the prompt minimal and deterministic (fewer model turns → smaller, more robust fixtures). 3. Record fixtures for every enabled provider (Workflow B). Host-only tests need no per-test recording: the shared empty fixture remains strict and fails on any model request. 4. **Review the diff** (Workflow B step 3), then run the test in plain replay mode to confirm it's green, then commit the test + fixtures together. +5. Run the full deterministic suite and coverage workflow described in the E2E README. +6. Open or update a draft PR, then complete the cross-platform Azure validation in Workflow D before considering the tests ready to merge. Provider-specific assertions go in that provider's `*.integrationTest.ts` after the `defineAgentHostE2ETests(config)` call. @@ -57,6 +59,19 @@ Real-time streaming, mid-turn aborts, and POSIX-specific local execution (shell Always add a comment explaining *why* the gate exists. Also add or update the corresponding entry in `e2e/KNOWN_ISSUES.md`. When the variant is enabled again, remove or update the entry in the same change. +## Workflow D — Cross-platform Azure validation + +New Agent Host E2E tests are not ready to merge after local replay alone. Push the branch, open or update a draft PR, then use the `azure-pipelines` skill to validate the real packaged Electron integration-test path. + +1. Queue VS Code pipeline definition `111` with `VSCODE_BUILD_TYPE=CI`; enable Windows, Linux, and macOS x64 while disabling publishing, release, Web, ARM, Alpine, and Snap artifacts. The `azure-pipelines` skill contains the canonical command. +2. Monitor jobs as they finish. Inspect a failed platform's Electron integration-test task immediately rather than waiting for unrelated stages to complete. +3. Treat the Agent Host E2E result as accepted only when the Electron integration tests succeed on Windows, Linux, and macOS. +4. Rerun an apparently unrelated or pre-existing failure in isolation before attributing it to the PR. +5. After a platform-specific fix, rerun at least that platform. Rerun all three platforms when the fix can affect shared behavior, provider fixtures, process lifecycle, or cross-platform paths. +6. Cancel obsolete builds after pushing a replacement commit. + +For additions involving timing, filesystem watching, process lifecycle, worktrees, reconnect/restart, or other known flake surfaces, require **two clean executions of every new test on each supported platform** before merge. A full three-platform build plus a targeted second build is sufficient when the second build runs the relevant tests on all affected platforms. + ## Verifying & troubleshooting - Run a single provider in replay: `./scripts/test-integration.sh --run ` (no env var). diff --git a/.github/skills/auto-perf-optimize/scratchpad/README.md b/.github/skills/auto-perf-optimize/scratchpad/README.md index 67a486c6759331..5ca5d3ff67e5d3 100644 --- a/.github/skills/auto-perf-optimize/scratchpad/README.md +++ b/.github/skills/auto-perf-optimize/scratchpad/README.md @@ -49,7 +49,9 @@ These are reusable, generic runners. Use them directly or as templates: - **`chat-session-switch-smoke.mts`** — Creates multiple chat sessions with different content, then repeatedly switches between them via the sessions sidebar. Measures per-switch memory growth. - +- **`workbench-css-performance.mts`** — Measures Modern UI style and layout + cost while resizing, opening/switching/closing editor tabs, and toggling + workbench parts. Writes per-round data and medians to `summary.json`. - **`userDataProfile.mts`** — Utility for managing user-data profiles in smoke test runs. diff --git a/.github/skills/auto-perf-optimize/scripts/workbench-css-performance.mts b/.github/skills/auto-perf-optimize/scripts/workbench-css-performance.mts new file mode 100644 index 00000000000000..8bacef7f9dd584 --- /dev/null +++ b/.github/skills/auto-perf-optimize/scripts/workbench-css-performance.mts @@ -0,0 +1,772 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Measures Modern UI style and layout cost across resize, tab, and part-toggle + * workflows. Writes raw rounds, medians, and screenshots to an output folder. + */ + +import { chromium, type Browser, type CDPSession, type Page } from 'playwright-core'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, readlinkSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { setTimeout as timeout } from 'node:timers/promises'; + +const root = path.resolve(import.meta.dirname, '..', '..', '..', '..'); +const options = parseArgs(process.argv.slice(2)); +const phaseNames = ['resize', 'class-mutations', 'open-tabs', 'switch-tabs', 'close-tabs', 'toggle-parts'] as const; + +interface SmokeTestDriver { + whenWorkbenchRestored(): Promise; +} + +declare global { + var driver: SmokeTestDriver | undefined; +} + +type PhaseName = typeof phaseNames[number]; + +interface Options { + help: boolean; + verbose: boolean; + skipPrelaunch: boolean; + keepOpen: boolean; + port: number; + rounds: number; + warmupRounds: number; + tabCount: number; + tabsPerRound: number; + resizesPerRound: number; + switchesPerRound: number; + partTogglesPerRound: number; + classMutationsPerRound: number; + outputDir: string; + workspace: string; + codeRoot: string; +} + +interface MetricSnapshot { + [name: string]: number; +} + +interface PhaseResult { + round: number; + phase: PhaseName; + wallTimeMs: number; + recalcStyleDurationMs: number; + layoutDurationMs: number; + scriptDurationMs: number; + taskDurationMs: number; + recalcStyleCount: number; + layoutCount: number; + tabCountBefore: number; + tabCountAfter: number; +} + +interface Summary { + createdAt: string; + error?: string; + options: Omit; + state: { + modernUIEnabled: boolean; + nodeCount: number; + initialTabCount: number; + finalTabCount?: number; + classMutationTargetCount: number; + sourceRevision: string; + }; + results: PhaseResult[]; + aggregate?: Record>; +} + +interface LaunchedCode { + child: ChildProcess; + failedBeforeConnect: Promise; + markConnected(): void; + terminate(): Promise; +} + +if (options.help) { + printHelp(); + process.exit(0); +} + +await main(); + +async function main(): Promise { + const outputDir = path.resolve(options.outputDir); + const workspace = path.resolve(options.workspace); + const userDataDir = path.join(os.tmpdir(), `vscode-css-perf-${process.pid}`); + const extensionsDir = path.join(os.tmpdir(), `vscode-css-perf-ext-${process.pid}`); + const summary: Summary = { + createdAt: new Date().toISOString(), + options: { + skipPrelaunch: options.skipPrelaunch, + port: options.port, + rounds: options.rounds, + warmupRounds: options.warmupRounds, + tabCount: options.tabCount, + tabsPerRound: options.tabsPerRound, + resizesPerRound: options.resizesPerRound, + switchesPerRound: options.switchesPerRound, + partTogglesPerRound: options.partTogglesPerRound, + classMutationsPerRound: options.classMutationsPerRound, + outputDir, + workspace, + codeRoot: options.codeRoot, + }, + state: { + modernUIEnabled: false, + nodeCount: 0, + initialTabCount: 0, + classMutationTargetCount: 0, + sourceRevision: 'unavailable', + }, + results: [], + }; + + let launchedCode: LaunchedCode | undefined; + let browser: Browser | undefined; + let session: CDPSession | undefined; + let processExitError: Error | undefined; + try { + summary.state.sourceRevision = getSourceRevision(); + await transpileClient(); + if (!options.skipPrelaunch) { + await prepareCode(); + } + await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 }); + await rm(extensionsDir, { recursive: true, force: true, maxRetries: 3 }); + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(userDataDir, 'User'), { recursive: true }); + await mkdir(extensionsDir, { recursive: true }); + await writeFile(path.join(userDataDir, 'User', 'settings.json'), JSON.stringify({ + 'workbench.experimental.modernUI': true, + 'window.density.layout': 'default', + 'workbench.startupEditor': 'none', + 'workbench.editor.enablePreview': false, + 'workbench.editor.showTabs': 'multiple', + 'workbench.editor.wrapTabs': false, + 'workbench.editor.pinnedTabSizing': 'normal', + 'workbench.editor.tabActionLocation': 'right', + 'window.commandCenter': true, + }, undefined, '\t')); + + if (await isCDPAvailable(options.port)) { + throw new Error(`Port ${options.port} already exposes CDP.`); + } + + launchedCode = launchCode(userDataDir, extensionsDir, workspace); + browser = await connectToCode(options.port, launchedCode.failedBeforeConnect); + launchedCode.markConnected(); + const page = await findWorkbenchPage(browser); + session = await page.context().newCDPSession(page); + await session.send('Performance.enable', { timeDomain: 'timeTicks' }); + await page.evaluate(() => globalThis.driver?.whenWorkbenchRestored()); + await waitForFrames(page, 4); + + const initialState = await page.evaluate(() => ({ + modernUIEnabled: document.querySelector('.monaco-workbench')?.classList.contains('modern-ui-tabs') === true, + nodeCount: document.querySelectorAll('*').length, + tabCount: document.querySelectorAll('.tabs-container .tab').length, + })); + if (!initialState.modernUIEnabled) { + throw new Error('Modern UI did not activate from the benchmark profile.'); + } + summary.state.modernUIEnabled = initialState.modernUIEnabled; + summary.state.nodeCount = initialState.nodeCount; + summary.state.initialTabCount = initialState.tabCount; + + await ensureTabCount(page, options.tabCount); + summary.state.classMutationTargetCount = await getClassMutationTargetCount(page); + await page.screenshot({ path: path.join(outputDir, '01-warmed-workbench.png') }); + + for (let round = 1; round <= options.warmupRounds; round++) { + await runRound(page, session, round, false, summary); + } + + for (let round = 1; round <= options.rounds; round++) { + await runRound(page, session, round, true, summary); + await writeSummary(outputDir, summary); + } + + summary.state.finalTabCount = await getTabCount(page); + summary.aggregate = aggregateResults(summary.results); + await page.screenshot({ path: path.join(outputDir, '02-final-workbench.png') }); + await writeSummary(outputDir, summary); + printSummary(summary); + } catch (error) { + summary.error = error instanceof Error && error.stack ? error.stack : String(error); + await writeSummary(outputDir, summary); + throw error; + } finally { + if (browser && !options.keepOpen) { + const browserSession = await settleWithin(browser.newBrowserCDPSession(), 2000); + if (browserSession) { + await settleWithin(browserSession.send('Browser.close'), 5000); + await settleWithin(browserSession.detach(), 1000); + } + } + await session?.detach().catch(() => undefined); + await settleWithin(browser?.close() ?? Promise.resolve(), 2000); + if (launchedCode && !options.keepOpen) { + if (!await waitForChildExit(launchedCode.child, 10000)) { + await launchedCode.terminate(); + if (!await waitForChildExit(launchedCode.child, 5000)) { + processExitError = new Error(`Code process tree did not exit after termination. pid=${launchedCode.child.pid}`); + } + } + } + if (!options.keepOpen) { + await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined); + await rm(extensionsDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined); + } + } + if (processExitError) { + summary.error = processExitError.stack ?? processExitError.message; + await writeSummary(outputDir, summary); + throw processExitError; + } +} + +async function runRound(page: Page, session: CDPSession, round: number, record: boolean, summary: Summary): Promise { + await ensureTabCount(page, options.tabCount); + await measurePhase(page, session, round, 'resize', record, summary, () => resizeWindow(page, session)); + await measurePhase(page, session, round, 'class-mutations', record, summary, () => mutateUnreferencedClasses(page)); + await measurePhase(page, session, round, 'open-tabs', record, summary, () => openTabs(page, options.tabsPerRound)); + await measurePhase(page, session, round, 'switch-tabs', record, summary, () => switchTabs(page, options.switchesPerRound)); + await measurePhase(page, session, round, 'close-tabs', record, summary, () => closeTabs(page, options.tabsPerRound)); + await measurePhase(page, session, round, 'toggle-parts', record, summary, () => toggleWorkbenchParts(page, options.partTogglesPerRound)); +} + +async function measurePhase( + page: Page, + session: CDPSession, + round: number, + phase: PhaseName, + record: boolean, + summary: Summary, + run: () => Promise, +): Promise { + const tabCountBefore = await getTabCount(page); + const metricsBefore = await getPerformanceMetrics(session); + const started = performance.now(); + await run(); + await waitForFrames(page, 2); + const wallTimeMs = performance.now() - started; + const metricsAfter = await getPerformanceMetrics(session); + const result: PhaseResult = { + round, + phase, + wallTimeMs, + recalcStyleDurationMs: metricDelta(metricsBefore, metricsAfter, 'RecalcStyleDuration') * 1000, + layoutDurationMs: metricDelta(metricsBefore, metricsAfter, 'LayoutDuration') * 1000, + scriptDurationMs: metricDelta(metricsBefore, metricsAfter, 'ScriptDuration') * 1000, + taskDurationMs: metricDelta(metricsBefore, metricsAfter, 'TaskDuration') * 1000, + recalcStyleCount: metricDelta(metricsBefore, metricsAfter, 'RecalcStyleCount'), + layoutCount: metricDelta(metricsBefore, metricsAfter, 'LayoutCount'), + tabCountBefore, + tabCountAfter: await getTabCount(page), + }; + if (record) { + summary.results.push(result); + console.log(formatResult(result)); + } +} + +async function resizeWindow(page: Page, session: CDPSession): Promise { + const sizes = [ + { width: 1120, height: 760 }, + { width: 1540, height: 940 }, + ]; + for (let index = 0; index < options.resizesPerRound; index++) { + const size = sizes[index % sizes.length]; + await session.send('Emulation.setDeviceMetricsOverride', { + width: size.width, + height: size.height, + deviceScaleFactor: 1, + mobile: false, + }); + await page.waitForFunction(expected => window.innerWidth === expected.width && window.innerHeight === expected.height, size); + await waitForFrames(page, 1); + } +} + +async function openTabs(page: Page, count: number): Promise { + const initialCount = await getTabCount(page); + for (let index = 1; index <= count; index++) { + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N'); + await waitForTabCount(page, initialCount + index); + } +} + +async function mutateUnreferencedClasses(page: Page): Promise { + await page.evaluate(iterations => { + const elements = Array.from(document.querySelectorAll('.codicon, .predefined-file-icon, .tab-label, .monaco-icon-label')); + if (elements.length === 0) { + throw new Error('No class mutation targets were found.'); + } + for (let index = 0; index < iterations; index++) { + const className = `css-performance-probe-${index % 2}`; + for (const element of elements) { + element.classList.add(className); + } + void document.body.offsetWidth; + for (const element of elements) { + element.classList.remove(className); + } + void document.body.offsetWidth; + } + }, options.classMutationsPerRound); +} + +async function getClassMutationTargetCount(page: Page): Promise { + return page.locator('.codicon, .predefined-file-icon, .tab-label, .monaco-icon-label').count(); +} + +async function switchTabs(page: Page, count: number): Promise { + const tabs = page.locator('.tabs-container .tab'); + const tabCount = await tabs.count(); + if (tabCount < 2) { + throw new Error(`Cannot switch tabs with only ${tabCount} tab(s).`); + } + for (let index = 0; index < count; index++) { + await tabs.nth(index % tabCount).click(); + await page.waitForFunction(expectedIndex => { + const candidates = Array.from(document.querySelectorAll('.tabs-container .tab')); + return candidates[expectedIndex]?.classList.contains('active') === true; + }, index % tabCount); + } +} + +async function closeTabs(page: Page, count: number): Promise { + const initialCount = await getTabCount(page); + for (let index = 1; index <= count; index++) { + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+W' : 'Control+W'); + await waitForTabCount(page, initialCount - index); + } +} + +async function toggleWorkbenchParts(page: Page, count: number): Promise { + const initialState = await getWorkbenchVisibilityState(page); + for (let index = 0; index < count; index++) { + await toggleWorkbenchClass(page, process.platform === 'darwin' ? 'Meta+B' : 'Control+B', 'nosidebar'); + await toggleWorkbenchClass(page, process.platform === 'darwin' ? 'Meta+J' : 'Control+J', 'nopanel'); + } + const finalState = await getWorkbenchVisibilityState(page); + if (finalState.sideBarHidden !== initialState.sideBarHidden) { + await toggleWorkbenchClass(page, process.platform === 'darwin' ? 'Meta+B' : 'Control+B', 'nosidebar'); + } + if (finalState.panelHidden !== initialState.panelHidden) { + await toggleWorkbenchClass(page, process.platform === 'darwin' ? 'Meta+J' : 'Control+J', 'nopanel'); + } + const restoredState = await getWorkbenchVisibilityState(page); + if (restoredState.sideBarHidden !== initialState.sideBarHidden || restoredState.panelHidden !== initialState.panelHidden) { + throw new Error('Workbench parts did not return to their initial visibility state.'); + } +} + +async function toggleWorkbenchClass(page: Page, keybinding: string, className: string): Promise { + const wasSet = await page.locator('.monaco-workbench').evaluate((workbench, expectedClass) => workbench.classList.contains(expectedClass), className); + await page.keyboard.press(keybinding); + await page.waitForFunction(({ expectedClass, expectedState }) => { + const workbench = document.querySelector('.monaco-workbench'); + return workbench?.classList.contains(expectedClass) !== expectedState; + }, { expectedClass: className, expectedState: wasSet }); + await waitForFrames(page, 1); +} + +async function getWorkbenchVisibilityState(page: Page): Promise<{ sideBarHidden: boolean; panelHidden: boolean }> { + return page.locator('.monaco-workbench').evaluate(workbench => ({ + sideBarHidden: workbench.classList.contains('nosidebar'), + panelHidden: workbench.classList.contains('nopanel'), + })); +} + +async function ensureTabCount(page: Page, expected: number): Promise { + const current = await getTabCount(page); + if (current < expected) { + await openTabs(page, expected - current); + } else if (current > expected) { + await closeTabs(page, current - expected); + } +} + +async function getTabCount(page: Page): Promise { + return page.locator('.tabs-container .tab').count(); +} + +async function waitForTabCount(page: Page, count: number): Promise { + await page.waitForFunction(expected => document.querySelectorAll('.tabs-container .tab').length === expected, count); + await waitForFrames(page, 1); +} + +async function waitForFrames(page: Page, count: number): Promise { + await page.evaluate(async frameCount => { + for (let index = 0; index < frameCount; index++) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + }, count); +} + +async function getPerformanceMetrics(session: CDPSession): Promise { + const response = await session.send('Performance.getMetrics'); + return Object.fromEntries(response.metrics.map(metric => [metric.name, metric.value])); +} + +function metricDelta(before: MetricSnapshot, after: MetricSnapshot, name: string): number { + return (after[name] ?? 0) - (before[name] ?? 0); +} + +function aggregateResults(results: PhaseResult[]): Summary['aggregate'] { + return Object.fromEntries(phaseNames.map(phase => { + const phaseResults = results.filter(result => result.phase === phase); + return [phase, { + wallTimeMs: median(phaseResults.map(result => result.wallTimeMs)), + recalcStyleDurationMs: median(phaseResults.map(result => result.recalcStyleDurationMs)), + layoutDurationMs: median(phaseResults.map(result => result.layoutDurationMs)), + scriptDurationMs: median(phaseResults.map(result => result.scriptDurationMs)), + taskDurationMs: median(phaseResults.map(result => result.taskDurationMs)), + recalcStyleCount: median(phaseResults.map(result => result.recalcStyleCount)), + layoutCount: median(phaseResults.map(result => result.layoutCount)), + }]; + })) as Summary['aggregate']; +} + +function median(values: number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = values.toSorted((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +function formatResult(result: PhaseResult): string { + return [ + `round=${result.round}`, + `phase=${result.phase}`, + `wall=${result.wallTimeMs.toFixed(1)}ms`, + `style=${result.recalcStyleDurationMs.toFixed(2)}ms/${result.recalcStyleCount}`, + `layout=${result.layoutDurationMs.toFixed(2)}ms/${result.layoutCount}`, + `tabs=${result.tabCountBefore}->${result.tabCountAfter}`, + ].join(' '); +} + +function printSummary(summary: Summary): void { + console.log(`\nSummary: ${path.join(options.outputDir, 'summary.json')}`); + for (const phase of phaseNames) { + const result = summary.aggregate?.[phase]; + if (result) { + console.log(`${phase.padEnd(14)} wall=${result.wallTimeMs.toFixed(1)}ms style=${result.recalcStyleDurationMs.toFixed(2)}ms layout=${result.layoutDurationMs.toFixed(2)}ms`); + } + } +} + +async function writeSummary(outputDir: string, summary: Summary): Promise { + await mkdir(outputDir, { recursive: true }); + await writeFile(path.join(outputDir, 'summary.json'), JSON.stringify(summary, undefined, '\t')); +} + +function launchCode(userDataDir: string, extensionsDir: string, workspace: string): LaunchedCode { + let failBeforeConnect: (error: Error) => void = () => undefined; + let connected = false; + let terminating = false; + const failedBeforeConnect = new Promise(resolve => failBeforeConnect = resolve); + const args = [ + '.', + '--disable-extension=vscode.vscode-api-tests', + '--enable-smoke-test-driver', + '--disable-workspace-trust', + `--remote-debugging-port=${options.port}`, + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extensionsDir}`, + '--skip-welcome', + '--skip-release-notes', + '--disable-updates', + workspace, + ]; + const executable = resolveCodeExecutable(); + const child = spawn(executable, args, { + cwd: options.codeRoot, + env: { + ...process.env, + NODE_ENV: 'development', + VSCODE_DEV: '1', + VSCODE_CLI: '1', + ELECTRON_ENABLE_LOGGING: '1', + ELECTRON_ENABLE_STACK_DUMPING: '1', + }, + detached: options.keepOpen, + stdio: options.verbose ? 'inherit' : 'ignore', + }); + if (options.keepOpen) { + child.unref(); + } + + child.once('error', error => failBeforeConnect(new Error(`Failed to launch Code from ${executable}: ${error.message}`))); + child.once('exit', (code, signal) => { + if (!connected && !terminating) { + failBeforeConnect(new Error(`Code exited before CDP connected. code=${code} signal=${signal}`)); + } + }); + return { + child, + failedBeforeConnect, + markConnected: () => connected = true, + terminate: async () => { + terminating = true; + await terminateProcessTree(child); + }, + }; +} + +function transpileClient(): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'transpile-client'], { + cwd: options.codeRoot, + env: process.env, + shell: process.platform === 'win32', + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`Client transpile failed. code=${code} signal=${signal}`))); + }); +} + +function prepareCode(): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(options.codeRoot, 'build', 'lib', 'preLaunch.ts')], { + cwd: options.codeRoot, + env: process.env, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`Code prelaunch failed. code=${code} signal=${signal}`))); + }); +} + +function resolveCodeExecutable(): string { + const product: Record = JSON.parse(readFileSync(path.join(options.codeRoot, 'product.json'), 'utf8')); + if (process.platform === 'darwin') { + return path.join(options.codeRoot, '.build', 'electron', `${requiredProductString(product, 'nameLong')}.app`, 'Contents', 'MacOS', requiredProductString(product, 'nameShort')); + } + if (process.platform === 'win32') { + return path.join(options.codeRoot, '.build', 'electron', `${requiredProductString(product, 'nameShort')}.exe`); + } + return path.join(options.codeRoot, '.build', 'electron', requiredProductString(product, 'applicationName')); +} + +function requiredProductString(product: Record, key: string): string { + const value = product[key]; + if (typeof value !== 'string' || !value) { + throw new Error(`product.json does not define ${key}.`); + } + return value; +} + +function getSourceRevision(): string { + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: options.codeRoot, encoding: 'utf8' }).trim(); + const status = execFileSync('git', ['status', '--short', '--untracked-files=all'], { cwd: options.codeRoot, encoding: 'utf8' }).trim(); + if (!status) { + return revision; + } + + const hash = createHash('sha256'); + hash.update(status); + hash.update(execFileSync('git', ['diff', '--binary', 'HEAD', '--'], { cwd: options.codeRoot, maxBuffer: 50 * 1024 * 1024 })); + const untrackedFiles = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: options.codeRoot }) + .toString('utf8') + .split('\0') + .filter(Boolean); + for (const file of untrackedFiles) { + const absolutePath = path.join(options.codeRoot, file); + hash.update(file); + const stat = lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + hash.update(readlinkSync(absolutePath)); + } else if (stat.isFile()) { + hash.update(readFileSync(absolutePath)); + } + } + return `${revision} (dirty:${hash.digest('hex').slice(0, 12)})`; +} + +async function settleWithin(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise.catch(() => undefined), + timeout(timeoutMs).then(() => undefined), + ]); +} + +function terminateProcessTree(child: ChildProcess): Promise { + if (child.pid === undefined) { + return Promise.resolve(); + } + if (process.platform !== 'win32') { + child.kill('SIGTERM'); + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + taskkill.once('error', reject); + taskkill.once('exit', code => code === 0 ? resolve() : reject(new Error(`taskkill failed with exit code ${code}`))); + }); +} + +async function connectToCode(port: number, launchFailure: Promise): Promise { + const endpoint = `http://127.0.0.1:${port}`; + for (let index = 0; index < 120; index++) { + try { + await Promise.race([waitForCDPEndpoint(port), launchFailure.then(error => Promise.reject(error))]); + return await chromium.connectOverCDP(endpoint); + } catch { + const launchError = await Promise.race([ + launchFailure, + new Promise(resolve => queueMicrotask(() => resolve(undefined))), + ]); + if (launchError) { + throw launchError; + } + await timeout(500); + } + } + throw new Error(`Timed out waiting for CDP on ${endpoint}.`); +} + +async function findWorkbenchPage(browser: Browser): Promise { + for (let index = 0; index < 120; index++) { + for (const page of browser.contexts().flatMap(context => context.pages())) { + if (await page.evaluate(() => !!globalThis.driver?.whenWorkbenchRestored).catch(() => false)) { + return page; + } + } + await timeout(500); + } + throw new Error('Timed out waiting for the workbench page.'); +} + +function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + return new Promise(resolve => { + const timer = setTimeout(() => resolve(false), timeoutMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +async function isCDPAvailable(port: number): Promise { + return waitForCDPEndpoint(port).then(() => true, () => false); +} + +async function waitForCDPEndpoint(port: number): Promise { + await new Promise((resolve, reject) => { + const request = http.get(`http://127.0.0.1:${port}/json/version`, response => { + response.resume(); + response.once('end', () => response.statusCode === 200 ? resolve() : reject(new Error(`HTTP ${response.statusCode}`))); + }); + request.setTimeout(1000, () => request.destroy(new Error('Request timed out'))); + request.once('error', reject); + }); +} + +function parseArgs(args: string[]): Options { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultOutputDir = path.join(root, '.build', 'css-performance', timestamp); + const result: Options = { + help: false, + verbose: false, + skipPrelaunch: false, + keepOpen: false, + port: 9231, + rounds: 7, + warmupRounds: 3, + tabCount: 12, + tabsPerRound: 4, + resizesPerRound: 10, + switchesPerRound: 16, + partTogglesPerRound: 4, + classMutationsPerRound: 40, + outputDir: defaultOutputDir, + workspace: path.join(defaultOutputDir, 'workspace'), + codeRoot: root, + }; + let workspaceWasSet = false; + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === '--help' || argument === '-h') { + result.help = true; + } else if (argument === '--verbose') { + result.verbose = true; + } else if (argument === '--skip-prelaunch') { + result.skipPrelaunch = true; + } else if (argument === '--keep-open') { + result.keepOpen = true; + } else if (argument === '--port') { + result.port = readNumber(args, ++index, argument); + } else if (argument === '--rounds') { + result.rounds = readNumber(args, ++index, argument); + } else if (argument === '--warmup-rounds') { + result.warmupRounds = readNumber(args, ++index, argument); + } else if (argument === '--output') { + result.outputDir = path.resolve(readValue(args, ++index, argument)); + } else if (argument === '--workspace') { + result.workspace = path.resolve(readValue(args, ++index, argument)); + workspaceWasSet = true; + } else if (argument === '--code-root') { + result.codeRoot = path.resolve(readValue(args, ++index, argument)); + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + if (result.rounds < 1 || result.warmupRounds < 0) { + throw new Error('Rounds must be positive and warmup rounds cannot be negative.'); + } + if (!workspaceWasSet) { + result.workspace = path.join(result.outputDir, 'workspace'); + } + return result; +} + +function readNumber(args: string[], index: number, option: string): number { + const value = Number(readValue(args, index, option)); + if (!Number.isInteger(value)) { + throw new Error(`${option} expects an integer.`); + } + return value; +} + +function readValue(args: string[], index: number, option: string): string { + const value = args[index]; + if (!value) { + throw new Error(`${option} expects a value.`); + } + return value; +} + +function printHelp(): void { + console.log(`Usage: node workbench-css-performance.mts [options] + +Options: + --rounds Measured rounds (default: 7) + --warmup-rounds Warmup rounds (default: 3) + --port DevTools port (default: 9231) + --output Artifact directory + --workspace Throwaway workspace + --code-root VS Code checkout to launch (default: current checkout) + --skip-prelaunch Skip Electron/extensions prelaunch preparation + --keep-open Leave the Code OSS window open + --verbose Stream Code OSS output`); +} diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md index 5672a0a58110bb..53aa7f96a25a52 100644 --- a/.github/skills/component-fixtures/SKILL.md +++ b/.github/skills/component-fixtures/SKILL.md @@ -61,6 +61,26 @@ Key points: - Always register created widgets with `disposableStore.add(...)` to prevent leaks - Pass `colorTheme: theme` to `createEditorServices` so theme colors render correctly +### File icon themes + +Fixtures use Seti file icons by default. Select another built-in theme, or disable file icons, on the individual fixture: + +```typescript +defineComponentFixture({ fileIconTheme: 'vs-minimal', render: renderMyComponent }); +defineComponentFixture({ fileIconTheme: 'none', render: renderMyComponent }); +``` + +When the rendered component reads `IThemeService`, pass the selected theme from `ComponentFixtureContext` to `createEditorServices`: + +```typescript +function renderMyComponent({ disposableStore, theme, fileIconTheme }: ComponentFixtureContext): void { + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + fileIconTheme, + }); +} +``` + ## Utilities from fixtureUtils.ts | Export | Purpose | diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index 015e6be556186d..ad04e126c4c1b7 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -182,9 +182,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "peer": true, "engines": { @@ -292,21 +292,21 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -316,6 +316,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -645,9 +659,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -792,9 +806,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", "license": "MIT", "peer": true, "engines": { @@ -847,9 +861,9 @@ "peer": true }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "peer": true, "engines": { diff --git a/build/azure-pipelines/dependencies-check.yml b/build/azure-pipelines/dependencies-check.yml index 2c81602695bb9b..af659e714bdc77 100644 --- a/build/azure-pipelines/dependencies-check.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -22,10 +22,13 @@ jobs: variables: VSCODE_ARCH: x64 steps: - - task: NodeTool@0 + - script: | + echo "##vso[task.setvariable variable=NODE_VERSION]$(cat .nvmrc)" + displayName: Read Node version from .nvmrc + + - task: UseNode@1 inputs: - versionSource: fromFile - versionFilePath: .nvmrc + version: $(NODE_VERSION) - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get GitHub token" @@ -84,6 +87,30 @@ jobs: - script: | set -e + # Check if any build subfolders with package-lock.json were modified + git fetch origin main + CHANGED_FILES=$(git diff --name-only origin/main...HEAD) + MODIFIED_BUILD_FOLDERS="" + + # Check if build/package-lock.json itself was modified + if echo "$CHANGED_FILES" | grep -q "^build/package-lock\.json$"; then + MODIFIED_BUILD_FOLDERS="build" + echo "build/package-lock.json was modified" + fi + + # Find all modified package-lock.json files under build subfolders and extract their directories + for file in $CHANGED_FILES; do + if [[ $file =~ ^(build/.+)/package-lock\.json$ ]]; then + dir="${BASH_REMATCH[1]}" + if [[ ! " $MODIFIED_BUILD_FOLDERS " =~ " $dir " ]]; then + MODIFIED_BUILD_FOLDERS="$MODIFIED_BUILD_FOLDERS $dir" + echo "$dir was modified" + fi + fi + done + + echo "##vso[task.setvariable variable=MODIFIED_BUILD_FOLDERS]$MODIFIED_BUILD_FOLDERS" + for attempt in {1..120}; do if [ $attempt -gt 1 ]; then echo "Attempt $attempt: Waiting for 10 minutes before retrying..." @@ -92,6 +119,15 @@ jobs: echo "Attempt $attempt: Running npm ci" if npm i --ignore-scripts; then + # Rebuild each modified build subfolder separately + for folder in $MODIFIED_BUILD_FOLDERS; do + echo "Rebuilding modified $folder..." + if ! (cd "$folder" && npm i --ignore-scripts); then + echo "npm i failed for $folder on attempt $attempt" + continue 2 + fi + done + if node build/npm/postinstall.ts; then echo "npm i succeeded on attempt $attempt" exit 0 diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index b2c62d5a7154de..5fcf81dbe211c4 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -260,57 +260,63 @@ extends: - script: | set -euo pipefail - if [ "$(Build.Reason)" = "PullRequest" ]; then - # Azure normally checks out a synthetic merge commit for PR validation. Its first - # parent is the target and its second parent is the PR head. Fall back to finding - # the merge base when the provider checks out the PR head directly. - parent_count=$(git rev-list --parents -n 1 HEAD | awk '{ print NF - 1 }') - if [ "$parent_count" = "2" ]; then - base_sha=$(git rev-parse HEAD^1) - head_sha=$(git rev-parse HEAD^2) - else - target_branch="$(System.PullRequest.TargetBranch)" - target_branch="${target_branch#refs/heads/}" - git fetch origin "$target_branch:refs/remotes/origin/$target_branch" - head_sha=$(git rev-parse HEAD) - base_sha=$(git merge-base "origin/$target_branch" "$head_sha") - fi + # Only validate trailers for PR builds + if [ "$(Build.Reason)" != "PullRequest" ]; then + echo "Skipping trailer check for non-PR builds" + exit 0 + fi + + # Azure normally checks out a synthetic merge commit for PR validation. Its first + # parent is the target and its second parent is the PR head. Fall back to finding + # the merge base when the provider checks out the PR head directly. + parent_count=$(git rev-list --parents -n 1 HEAD | awk '{ print NF - 1 }') + if [ "$parent_count" = "2" ]; then + base_sha=$(git rev-parse HEAD^1) + head_sha=$(git rev-parse HEAD^2) else - # Manual builds validate the selected branch's latest commit. + target_branch="$(System.PullRequest.TargetBranch)" + target_branch="${target_branch#refs/heads/}" + git fetch origin "$target_branch:refs/remotes/origin/$target_branch" head_sha=$(git rev-parse HEAD) - base_sha=$(git rev-parse HEAD^1) + base_sha=$(git merge-base "origin/$target_branch" "$head_sha") fi - # MSRC pull requests must be squashed to one commit before validation succeeds. - commit_count=$(git rev-list --count "$base_sha..$head_sha") - if [ "$commit_count" != "1" ]; then - echo "##vso[task.logissue type=error]This PR has $commit_count commits. release/msrc/* PRs must contain exactly one commit. Please squash your commits." - exit 1 - fi - echo "PR has a single commit." + # Check each commit in the PR for the Msrc-Case-Id trailer + commits=$(git rev-list "$base_sha..$head_sha") + commits_with_trailer=0 + total_commits=0 - # Extract every Msrc-Case-Id trailer value from the PR head commit. - mapfile -t trailer_values < <( - git log -1 --pretty='format:%(trailers:key=Msrc-Case-Id,valueonly=true)' "$head_sha" | - sed '/^[[:space:]]*$/d' - ) + for commit in $commits; do + total_commits=$((total_commits + 1)) - if [ "${#trailer_values[@]}" = "0" ]; then - echo "##vso[task.logissue type=error]Commit $head_sha is missing the required 'Msrc-Case-Id' trailer." - printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" - exit 1 - fi + # Extract Msrc-Case-Id trailer values from this commit + mapfile -t trailer_values < <( + git log -1 --pretty='format:%(trailers:key=Msrc-Case-Id,valueonly=true)' "$commit" | + sed '/^[[:space:]]*$/d' + ) + + if [ "${#trailer_values[@]}" -gt 0 ]; then + commits_with_trailer=$((commits_with_trailer + 1)) - # Every supplied case ID must be numeric or explicitly marked not applicable. - for value in "${trailer_values[@]}"; do - if [[ ! "$value" =~ ^[0-9]+$ && "$value" != "N/A" ]]; then - echo "##vso[task.logissue type=error]Commit $head_sha has an invalid 'Msrc-Case-Id' trailer value. Expected a number or N/A." - printf "Use a numeric case id or N/A, for example:\n\n Msrc-Case-Id: 12345\n\n" - exit 1 + # Validate all trailer values are numeric or N/A + for value in "${trailer_values[@]}"; do + if [[ ! "$value" =~ ^[0-9]+$ && "$value" != "N/A" ]]; then + echo "##vso[task.logissue type=error]Commit $commit has an invalid 'Msrc-Case-Id' trailer value. Expected a number or N/A." + printf "Use a numeric case id or N/A, for example:\n\n Msrc-Case-Id: 12345\n\n" + exit 1 + fi + done + + echo "Commit $commit has the following 'Msrc-Case-Id' trailers: ${trailer_values[*]}" fi done - echo "Commit $head_sha has the required 'Msrc-Case-Id' trailer." + # At least one commit must have the Msrc-Case-Id trailer + if [ "$commits_with_trailer" -lt 1 ]; then + echo "##vso[task.logissue type=error]Expected at least one commit with 'Msrc-Case-Id' trailer, but found $commits_with_trailer out of $total_commits commits with a trailer." + printf "Add a trailer to at least one commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" + exit 1 + fi displayName: Verify Commit and Trailer - stage: Quality diff --git a/build/filters.ts b/build/filters.ts index 106565b3b2b7cb..c62c8260e72e02 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -266,5 +266,10 @@ export const eslintFilter = Object.freeze([ ]); export const stylelintFilter = Object.freeze([ - 'src/**/*.css' + 'src/**/*.css', + 'extensions/**/*.css', + '!extensions/**/node_modules/**', + '!extensions/**/test/**', + '!extensions/**/test-data/**', + '!extensions/**/testData/**', ]); diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 1962a937ec3ec7..a61a6525255bb5 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -61,7 +61,7 @@ "localization": { "description": { "key": "chat.agent.allowedNetworkDomains", - "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." + "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "array", @@ -76,7 +76,7 @@ "localization": { "description": { "key": "chat.agent.deniedNetworkDomains", - "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." + "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "array", @@ -106,7 +106,7 @@ "localization": { "description": { "key": "chat.agent.networkFilter", - "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled." + "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted." } }, "type": "boolean", diff --git a/build/lib/stylelint/validateHasSelectors.ts b/build/lib/stylelint/validateHasSelectors.ts index 44081989f77cdf..2e450b7513ba97 100644 --- a/build/lib/stylelint/validateHasSelectors.ts +++ b/build/lib/stylelint/validateHasSelectors.ts @@ -10,6 +10,7 @@ const CharacterCode = { CarriageReturn: 13, Space: 32, DoubleQuote: 34, + Dollar: 36, Ampersand: 38, SingleQuote: 39, LeftParenthesis: 40, @@ -23,12 +24,15 @@ const CharacterCode = { Colon: 58, Semicolon: 59, LessThan: 60, + Equals: 61, GreaterThan: 62, At: 64, LeftSquareBracket: 91, Backslash: 92, RightSquareBracket: 93, + Circumflex: 94, LeftCurlyBracket: 123, + VerticalBar: 124, RightCurlyBracket: 125, Tilde: 126, } as const; @@ -71,6 +75,152 @@ export function findRootAnchoredHas(css: string): number | undefined { return scanStylesheet(css, 0, css.length, false).rootAnchoredHasOffset; } +/** + * Returns the offset of the first substring operator used on a `class` + * attribute in a stylesheet selector. + */ +export function findClassAttributeSubstringSelector(css: string): number | undefined { + return scanStylesheetForClassAttributeSubstring(css, 0, css.length).offset; +} + +function scanStylesheetForClassAttributeSubstring(css: string, start: number, end: number): { next: number; offset: number | undefined } { + let index = start; + while (index < end) { + index = skipWhitespaceAndComments(css, index, end); + if (css.charCodeAt(index) === CharacterCode.RightCurlyBracket) { + return { next: index + 1, offset: undefined }; + } + + const preludeStart = index; + index = findStatementBoundary(css, index, end); + const boundary = css.charCodeAt(index); + if (boundary === CharacterCode.Semicolon) { + index++; + continue; + } + if (boundary === CharacterCode.RightCurlyBracket) { + return { next: index + 1, offset: undefined }; + } + if (boundary !== CharacterCode.LeftCurlyBracket) { + return { next: index, offset: undefined }; + } + + const contentStart = skipWhitespaceAndComments(css, preludeStart, index); + const isAtRule = css.charCodeAt(contentStart) === CharacterCode.At; + const declarationIdentifier = readIdentifier(css, contentStart, index); + const isCustomProperty = declarationIdentifier.value.startsWith('--') + && css.charCodeAt(skipWhitespaceAndComments(css, declarationIdentifier.end, index)) === CharacterCode.Colon; + if (isCustomProperty) { + index = skipCustomPropertyDeclaration(css, index, end); + continue; + } + if (isAtRule) { + const atRuleName = readIdentifier(css, contentStart + 1, index); + if (atRuleName.value === 'scope') { + const offset = findClassAttributeSubstringInSelector(css, atRuleName.end, trimTrailingWhitespace(css, atRuleName.end, index)); + if (offset !== undefined) { + return { next: index, offset }; + } + } + } else { + const offset = findClassAttributeSubstringInSelector(css, preludeStart, trimTrailingWhitespace(css, preludeStart, index)); + if (offset !== undefined) { + return { next: index, offset }; + } + } + + const blockResult = scanStylesheetForClassAttributeSubstring(css, index + 1, end); + if (blockResult.offset !== undefined) { + return blockResult; + } + index = blockResult.next; + } + return { next: end, offset: undefined }; +} + +function findClassAttributeSubstringInSelector(css: string, start: number, end: number): number | undefined { + for (let index = start; index < end;) { + const character = css.charCodeAt(index); + if (character === CharacterCode.LeftSquareBracket) { + const bracketEnd = skipBracket(css, index + 1, end); + const attribute = readAttributeName(css, index + 1, bracketEnd - 1); + const operator = css.charCodeAt(attribute.end); + if (attribute.value === 'class' + && (operator === CharacterCode.Asterisk || operator === CharacterCode.Circumflex || operator === CharacterCode.Dollar) + && css.charCodeAt(attribute.end + 1) === CharacterCode.Equals) { + if (!isLegacyCodiconClassSubstring(css, operator, attribute.end + 2, bracketEnd - 1)) { + return index; + } + } + index = bracketEnd; + } else if (character === CharacterCode.SingleQuote || character === CharacterCode.DoubleQuote) { + index = skipString(css, index + 1, end, character); + } else if (character === CharacterCode.Slash && css.charCodeAt(index + 1) === CharacterCode.Asterisk) { + index = skipComment(css, index + 2, end); + } else { + index++; + } + } + return undefined; +} + +function isLegacyCodiconClassSubstring(css: string, operator: number, start: number, end: number): boolean { + if (operator !== CharacterCode.Asterisk) { + return false; + } + let index = skipWhitespaceAndComments(css, start, end); + const quote = css.charCodeAt(index); + if (quote === CharacterCode.SingleQuote || quote === CharacterCode.DoubleQuote) { + index++; + let value = ''; + while (index < end) { + const character = css.charCodeAt(index); + if (character === quote) { + return value.startsWith('codicon-'); + } + if (character === CharacterCode.Backslash) { + const escaped = readEscape(css, index + 1, end); + if (!escaped) { + return false; + } + value += escaped.value; + index = escaped.end; + } else { + value += css[index++]; + } + } + return false; + } + return css.slice(index, readUnquotedAttributeValueEnd(css, index, end)).startsWith('codicon-'); +} + +function readUnquotedAttributeValueEnd(css: string, start: number, end: number): number { + let index = start; + while (index < end && !isWhitespace(css.charCodeAt(index)) && css.charCodeAt(index) !== CharacterCode.RightSquareBracket) { + index++; + } + return index; +} + +function readAttributeName(css: string, start: number, end: number): Identifier { + let index = skipWhitespaceAndComments(css, start, end); + let attribute = readIdentifier(css, index, end); + index = skipWhitespaceAndComments(css, attribute.end, end); + + if (css.charCodeAt(index) === CharacterCode.Asterisk && css.charCodeAt(index + 1) === CharacterCode.VerticalBar) { + index = skipWhitespaceAndComments(css, index + 2, end); + attribute = readIdentifier(css, index, end); + } else if (css.charCodeAt(index) === CharacterCode.VerticalBar && css.charCodeAt(index + 1) !== CharacterCode.Equals) { + index = skipWhitespaceAndComments(css, index + 1, end); + attribute = readIdentifier(css, index, end); + } + + return { + value: attribute.value, + end: skipWhitespaceAndComments(css, attribute.end, end), + }; +} + function mayContainHasPseudo(css: string): boolean { for (let index = css.indexOf(':'); index >= 0; index = css.indexOf(':', index + 1)) { const firstCharacter = css.charCodeAt(index + 1); @@ -257,10 +407,13 @@ function readEscape(css: string, start: number, end: number): Identifier | undef if (digitCount === 0) { return { end: index + 1, value: css[index] }; } - if (isWhitespace(css.charCodeAt(index))) { + if (css.charCodeAt(index) === CharacterCode.CarriageReturn && css.charCodeAt(index + 1) === CharacterCode.LineFeed) { + index += 2; + } else if (isWhitespace(css.charCodeAt(index))) { index++; } - return { end: index, value: String.fromCodePoint(codePoint || 0xFFFD) }; + const validCodePoint = codePoint === 0 || codePoint > 0x10FFFF || codePoint >= 0xD800 && codePoint <= 0xDFFF ? 0xFFFD : codePoint; + return { end: index, value: String.fromCodePoint(validCodePoint) }; } function hexValue(character: number): number { @@ -300,6 +453,7 @@ function skipBracket(css: string, start: number, end: number): number { if (character === CharacterCode.RightSquareBracket) { return index + 1; } + if (character === CharacterCode.SingleQuote || character === CharacterCode.DoubleQuote) { index = skipString(css, index + 1, end, character) - 1; } else if (character === CharacterCode.Slash && css.charCodeAt(index + 1) === CharacterCode.Asterisk) { @@ -309,6 +463,37 @@ function skipBracket(css: string, start: number, end: number): number { return end; } +function skipCustomPropertyDeclaration(css: string, start: number, end: number): number { + let curlyDepth = 0; + let parenthesisDepth = 0; + for (let index = start; index < end; index++) { + const character = css.charCodeAt(index); + if (character === CharacterCode.LeftCurlyBracket) { + curlyDepth++; + } else if (character === CharacterCode.RightCurlyBracket) { + if (curlyDepth === 0) { + return index; + } + curlyDepth--; + } else if (character === CharacterCode.LeftParenthesis) { + parenthesisDepth++; + } else if (character === CharacterCode.RightParenthesis && parenthesisDepth > 0) { + parenthesisDepth--; + } else if (character === CharacterCode.Semicolon && curlyDepth === 0 && parenthesisDepth === 0) { + return index + 1; + } else if (character === CharacterCode.SingleQuote || character === CharacterCode.DoubleQuote) { + index = skipString(css, index + 1, end, character) - 1; + } else if (character === CharacterCode.LeftSquareBracket) { + index = skipBracket(css, index + 1, end) - 1; + } else if (character === CharacterCode.Slash && css.charCodeAt(index + 1) === CharacterCode.Asterisk) { + index = skipComment(css, index + 2, end) - 1; + } else if (character === CharacterCode.Backslash) { + index = (readEscape(css, index + 1, end)?.end ?? index + 1) - 1; + } + } + return end; +} + function findClosingParenthesis(css: string, start: number, end: number): number { let depth = 1; for (let index = start; index < end; index++) { @@ -404,6 +589,8 @@ function findStatementBoundary(css: string, start: number, end: number): number parenthesisDepth++; } else if (character === CharacterCode.RightParenthesis) { parenthesisDepth--; + } else if (character === CharacterCode.Backslash) { + index = (readEscape(css, index + 1, end)?.end ?? index + 1) - 1; } else if (parenthesisDepth === 0 && (character === CharacterCode.Semicolon || character === CharacterCode.LeftCurlyBracket || character === CharacterCode.RightCurlyBracket)) { return index; } diff --git a/build/lib/test/rootAnchoredHas.test.ts b/build/lib/test/rootAnchoredHas.test.ts index 653f443a1c27bb..11acdc05c77ac7 100644 --- a/build/lib/test/rootAnchoredHas.test.ts +++ b/build/lib/test/rootAnchoredHas.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { suite, test } from 'node:test'; -import { containsRootAnchoredHas, findRootAnchoredHas } from '../stylelint/validateHasSelectors.ts'; +import { containsRootAnchoredHas, findClassAttributeSubstringSelector, findRootAnchoredHas } from '../stylelint/validateHasSelectors.ts'; suite('stylelint root-anchored :has() check', () => { @@ -67,3 +67,50 @@ suite('stylelint root-anchored :has() check', () => { assert.ok(findRootAnchoredHas('.embedded { @media (width > 0) { &:has(.foo) {} } }') === undefined); }); }); + +suite('stylelint class attribute substring check', () => { + + test('flags every class substring operator', () => { + assert.ok(findClassAttributeSubstringSelector('.a[class*="icon"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.a[ class ^= \'icon\' i] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.a[CLASS$="icon"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.a[\\63 lass*="icon"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.a[|class*="icon"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.a[*|class^="icon"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('[cl\\61\r\nss*=x] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('--foo[class*=x] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('--foo { & [class*=x] {} }') !== undefined); + }); + + test('ignores stable class and non-substring attribute selectors', () => { + assert.strictEqual(findClassAttributeSubstringSelector('.codicon.codicon {}'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.codicon[class*="codicon-"] {}'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.icon[class*="codicon-debug"] {}'), undefined); + assert.ok(findClassAttributeSubstringSelector('.codicon[class*="CODICON-"] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.codicon[class*="codi/**/con-"] {}') !== undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.a[class~="icon"] {}'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.a[data-class*="icon"] {}'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.a[class="icon"] {}'), undefined); + }); + + test('scans only stylesheet selectors', () => { + assert.strictEqual(findClassAttributeSubstringSelector('/* .a[class*="icon"] {} */\n.foo {}'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.foo { content: "[class*=icon]"; }'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('@supports selector([class*="icon"]) { .foo {} }'), undefined); + }); + + test('scans nested stylesheet selectors', () => { + assert.ok(findClassAttributeSubstringSelector('.foo { &[class*="icon"] {} }') !== undefined); + assert.ok(findClassAttributeSubstringSelector('@media (width > 0) { .foo[class^="icon"] {} }') !== undefined); + }); + + test('handles scope, escaped delimiters, and custom property blocks', () => { + assert.ok(findClassAttributeSubstringSelector('@scope ([class*=foo]) { .x {} }') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.foo\\}bar[class*=foo] {}') !== undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.x { --tokens: { [class*=foo] {} }; }'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.x { --tokens: { a: b } [class*=foo] {}; }'), undefined); + assert.strictEqual(findClassAttributeSubstringSelector('.x { -\\-tokens: { [class*=foo] {} }; }'), undefined); + assert.ok(findClassAttributeSubstringSelector('.\\110000[class*=x] {}') !== undefined); + assert.ok(findClassAttributeSubstringSelector('.\\ffffff[class*=x] {}') !== undefined); + }); +}); diff --git a/build/lib/test/stylelint.test.ts b/build/lib/test/stylelint.test.ts index f7239627e6d4e3..1891ccd8e7818a 100644 --- a/build/lib/test/stylelint.test.ts +++ b/build/lib/test/stylelint.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import es from 'event-stream'; import { suite, test } from 'node:test'; import { stylelintFilter } from '../../filters.ts'; -import { resolveStylelintMatches, resolveStylelintSources } from '../../stylelint.ts'; +import gulpstylelint, { resolveStylelintMatches, resolveStylelintSources } from '../../stylelint.ts'; suite('stylelint', () => { @@ -15,6 +16,11 @@ suite('stylelint', () => { sources: Array.from(stylelintFilter), explicit: false, }); + + test('includes production extension CSS but excludes extension tests', () => { + assert.ok(stylelintFilter.includes('extensions/**/*.css')); + assert.ok(stylelintFilter.includes('!extensions/**/test/**')); + }); }); test('resolves multiple positional and path arguments', () => { @@ -35,6 +41,33 @@ suite('stylelint', () => { }); }); + test('excludes test CSS from production selector checks', async () => { + const [source, sourceTest, sourceTestData, windowsSourceTest, extension, extensionTest] = await Promise.all([ + hasClassAttributeSubstringError('src/vs/workbench/browser/example.css'), + hasClassAttributeSubstringError('src/vs/workbench/test/browser/componentFixtures/example.css'), + hasClassAttributeSubstringError('src/vs/workbench/test-data/example.css'), + hasClassAttributeSubstringError('src\\vs\\workbench\\test\\browser\\componentFixtures\\example.css'), + hasClassAttributeSubstringError('extensions/example/browser/example.css'), + hasClassAttributeSubstringError('extensions/example/test/example.css'), + ]); + + assert.deepStrictEqual({ + source, + sourceTest, + sourceTestData, + windowsSourceTest, + extension, + extensionTest, + }, { + source: true, + sourceTest: false, + sourceTestData: false, + windowsSourceTest: false, + extension: true, + extensionTest: false, + }); + }); + test('rejects missing path argument values', () => { assert.throws(() => resolveStylelintSources([ 'node', @@ -49,3 +82,21 @@ suite('stylelint', () => { ]), /No CSS files matched the requested path/); }); }); + +function hasClassAttributeSubstringError(relative: string): Promise { + return new Promise((resolve, reject) => { + const errors: string[] = []; + const stream = gulpstylelint((message, isError) => { + if (isError) { + errors.push(message); + } + }, false, false); + stream.on('data', () => undefined); + stream.once('error', reject); + stream.once('end', () => resolve(errors.some(message => message.includes('Class attribute substring selectors')))); + es.readArray([{ + relative, + contents: Buffer.from('.a[class*=x] {}'), + }]).pipe(stream); + }); +} diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 887285c3d96c8b..828fa31cf4a071 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -138,9 +138,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -439,15 +439,11 @@ } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "dev": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -488,13 +484,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -915,13 +904,13 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -944,13 +933,6 @@ "node": ">= 14" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -1069,9 +1051,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 760c9880d12214..58b6a7beacce27 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Configuration, HtmlRspackPlugin, rspack } from '@rspack/core'; +import { type Configuration, CopyRspackPlugin, HtmlRspackPlugin, rspack } from '@rspack/core'; import { ComponentExplorerPlugin } from '@vscode/component-explorer-webpack-plugin'; import fs from 'fs'; import net from 'net'; @@ -12,6 +12,10 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '../..'); const isStaticComponentExplorerBuild = process.env['COMPONENT_EXPLORER_STATIC_BUILD'] === '1'; +const builtInFileIconThemeDirectories = [ + 'extensions/theme-defaults/fileicons', + 'extensions/theme-seti/icons', +]; function findFreePort(startPort: number): Promise { return new Promise(resolve => { @@ -103,7 +107,7 @@ export default { }, }, { - // Built-in theme JSON files use JSONC (comments / trailing + // Built-in color theme JSON files use JSONC (comments / trailing // commas), so import them as raw strings and let VS Code's // JSON parser handle them. test: /[\\/]extensions[\\/]theme-defaults[\\/]themes[\\/].*\.json$/, @@ -112,6 +116,12 @@ export default { ], }, plugins: [ + ...(isStaticComponentExplorerBuild ? [new CopyRspackPlugin({ + patterns: builtInFileIconThemeDirectories.map(directory => ({ + from: path.join(repoRoot, directory), + to: directory, + })), + })] : []), new ComponentExplorerPlugin({ include: 'src/**/*.fixture.ts', }), diff --git a/build/stylelint.ts b/build/stylelint.ts index 4912c5e0b34de6..c4d4fe787c4bfc 100644 --- a/build/stylelint.ts +++ b/build/stylelint.ts @@ -7,7 +7,7 @@ import es from 'event-stream'; import glob from 'glob'; import vfs from 'vinyl-fs'; import { stylelintFilter } from './filters.ts'; -import { findRootAnchoredHas } from './lib/stylelint/validateHasSelectors.ts'; +import { findClassAttributeSubstringSelector, findRootAnchoredHas } from './lib/stylelint/validateHasSelectors.ts'; import { getVariableNameValidator } from './lib/stylelint/validateVariableNames.ts'; import { validateCodiconFontSizes, validateFontSizeTokens, validateFontWeightTokens, validateCornerRadiusTokens, validateSpacingTokens, validateStrokeTokens, validateDeprecatedTokens } from './lib/stylelint/validateDesignTokens.ts'; @@ -31,6 +31,9 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere let errorCount = 0; const monacoWorkbenchPattern = /\.monaco-workbench/; const restrictedPathPattern = /^src[\/\\]vs[\/\\](base|platform|editor)[\/\\]/; + const productionCssPattern = /^(?:src[\/\\]vs|extensions)[\/\\]/; + const extensionCssPattern = /^extensions[\/\\]/; + const testCssPattern = /[\/\\](?:test|test-data|testData)[\/\\]/; const designSystemPattern = /^src[\/\\]vs[\/\\]sessions[\/\\]/; const layerCheckerDisablePattern = /\/\*\s*stylelint-disable\s+layer-checker\s*\*\//; const hasAnchorCheckerDisablePattern = /^\s*\/\*\s*stylelint-disable\s+has-anchor-checker\s*\*\/\s*$/; @@ -53,10 +56,12 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere const isHasAnchorCheckerDisabled = lines.some(line => hasAnchorCheckerDisablePattern.test(line)); lines.forEach((line, i) => { - variableValidator(line, (unknownVariable: string) => { - reporter(file.relative + '(' + (i + 1) + ',1): Unknown variable: ' + unknownVariable, true); - errorCount++; - }); + if (!extensionCssPattern.test(file.relative)) { + variableValidator(line, (unknownVariable: string) => { + reporter(file.relative + '(' + (i + 1) + ',1): Unknown variable: ' + unknownVariable, true); + errorCount++; + }); + } if (isRestrictedPath && !isLayerCheckerDisabled && monacoWorkbenchPattern.test(line)) { reporter(file.relative + '(' + (i + 1) + ',1): The class .monaco-workbench cannot be used in files under src/vs/{base,platform,editor} because only src/vs/workbench applies it', true); @@ -70,6 +75,15 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere reporter(file.relative + '(' + lineNumberAtOffset(contents, rootAnchoredHasOffset) + ',1): Root-anchored :has() (on body/html/:root/.monaco-workbench) makes every DOM mutation pay workbench-wide style invalidation (see microsoft/vscode#324985). Toggle a class from code instead', true); errorCount++; } + + } + + if (productionCssPattern.test(file.relative) && !testCssPattern.test(file.relative)) { + const classAttributeSubstringOffset = findClassAttributeSubstringSelector(contents); + if (classAttributeSubstringOffset !== undefined) { + reporter(file.relative + '(' + lineNumberAtOffset(contents, classAttributeSubstringOffset) + ',1): Class attribute substring selectors make unrelated class mutations trigger style recalculation. Use a stable marker class instead', true); + errorCount++; + } } // Design-token checks that need block (selector + declaration) awareness. @@ -143,7 +157,7 @@ function stylelint(sources: string[] = Array.from(stylelintFilter), explicit = f let fileCount = 0; console.info(explicit ? `Stylelint: checking ${resolvedSources.length} CSS file${resolvedSources.length === 1 ? '' : 's'} matched by ${sources.length} requested path${sources.length === 1 ? '' : 's'}.` - : 'Stylelint: checking all CSS files under src.'); + : 'Stylelint: checking CSS files in the default src and extensions scope.'); return vfs .src(resolvedSources, { base: '.', follow: true, allowEmpty: !explicit }) .pipe(gulpstylelint((message, isError) => { diff --git a/cglicenses.json b/cglicenses.json index fab28867d222bd..b928f3808d9758 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -1227,5 +1227,33 @@ "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", "SOFTWARE" ] + }, + { + // Reason: @vscode/web-editors declares MIT in its package.json, but its npm + // tarball does not include a LICENSE file and ClearlyDefined does not cover it. + "name": "@vscode/web-editors", + "fullLicenseText": [ + "MIT License", + "", + "Copyright (c) Microsoft Corporation", + "", + "Permission is hereby granted, free of charge, to any person obtaining a copy", + "of this software and associated documentation files (the \"Software\"), to deal", + "in the Software without restriction, including without limitation the rights", + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell", + "copies of the Software, and to permit persons to whom the Software is", + "furnished to do so, subject to the following conditions:", + "", + "The above copyright notice and this permission notice shall be included in all", + "copies or substantial portions of the Software.", + "", + "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", + "SOFTWARE" + ] } ] diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index f2fcb50255496d..b58c056c6fffe3 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -93,7 +93,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod folders.push({ uri: URI.joinPath(folder, ...root.path), label: root.path[0], - source: 'local' + source: 'local', + destinationGroupId: URI.joinPath(folder, root.path[0]).toString(), }); } } @@ -103,7 +104,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod folders.push({ uri: URI.joinPath(this.envService.userHome, ...root.path), label: `~/${root.path[0]}`, - source: 'user' + source: 'user', + destinationGroupId: URI.joinPath(this.envService.userHome, root.path[0]).toString(), }); } } diff --git a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts index 30f5be683ce67d..768ab811be8957 100644 --- a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts +++ b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts @@ -36,6 +36,7 @@ import { ICustomInstructionsService } from '../../customInstructions/common/cust import { IPromptVariablesService } from '../../../extension/prompt/node/promptVariablesService'; import { arrayEqual } from 'diff/lib/util/array.js'; import { structuralEquals } from '../../../util/vs/base/common/equals'; +import { INativeEnvService } from '../../env/common/envService'; /** * Telemetry payload (parity with core's `instructionsCollected` event). @@ -136,6 +137,7 @@ export class AutomaticInstructionsCollector implements IAutomaticInstructionsCol @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILogService private readonly _logService: ILogService, @IExperimentationService private readonly _experimentationService: IExperimentationService, + @INativeEnvService private readonly _envService: INativeEnvService, ) { } @@ -316,7 +318,11 @@ export class AutomaticInstructionsCollector implements IAutomaticInstructionsCol // Resolve all referenced URIs from this file's body. const candidates: URI[] = []; for (const ref of parsed.body.fileReferences) { - const resolved = parsed.body.resolveFilePath(ref.content); + const resolved = ref.content === '~' + ? this._envService.userHome + : ref.content.startsWith('~/') + ? URI.joinPath(this._envService.userHome, ref.content.substring(2)) + : parsed.body.resolveFilePath(ref.content); if (!resolved || seen.has(resolved)) { continue; } diff --git a/extensions/copilot/src/platform/promptFiles/test/node/automaticInstructionsCollector.spec.ts b/extensions/copilot/src/platform/promptFiles/test/node/automaticInstructionsCollector.spec.ts index 26e49bd58452fe..d020a045512968 100644 --- a/extensions/copilot/src/platform/promptFiles/test/node/automaticInstructionsCollector.spec.ts +++ b/extensions/copilot/src/platform/promptFiles/test/node/automaticInstructionsCollector.spec.ts @@ -24,6 +24,7 @@ import { AutomaticInstructionsCollector, InstructionsCollectionEvent } from '../ import { InstructionFileIdPrefix, isCustomizationsIndex, isInstructionFile } from '../../../../extension/prompt/common/chatVariablesCollection'; import { ToolName } from '../../../../extension/tools/common/toolNames'; import { NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; +import { NullNativeEnvService } from '../../../env/common/nullEnvService'; const localSessionResource = URI.parse(`local://test`); @@ -108,7 +109,8 @@ suite('AutomaticInstructionsCollector', () => { extensionsService, telemetry, new LogServiceImpl([]), - new NullExperimentationService() + new NullExperimentationService(), + new NullNativeEnvService(), ); }); @@ -330,6 +332,26 @@ suite('AutomaticInstructionsCollector', () => { expect(paths).toContain(level3Uri.path); }); + test('resolves user home references', async () => { + const copilotUri = URI.joinPath(rootFolderUri, '.github/copilot-instructions.md'); + const referencedUri = URI.file('/home/testuser/referenced.instructions.md'); + + promptsService.setAgentInstructions([ + { uri: copilotUri, type: AgentInstructionFileType.copilotInstructionsMd }, + ]); + promptsService.setFileContent(copilotUri, 'See #file:~/referenced.instructions.md'); + promptsService.setFileContent(referencedUri, 'Referenced content'); + + await mockFiles(fileSystem, [ + { path: referencedUri.path, contents: ['Referenced content'] }, + ]); + + const result = await callCollect(); + + const paths = result.filter(e => isInstructionFile(e)).map(e => e.value.path); + expect(paths).toContain(referencedUri.path); + }); + test('skips references to files outside the workspace that are not prompt files', async () => { const copilotUri = URI.joinPath(rootFolderUri, '.github/copilot-instructions.md'); promptsService.setAgentInstructions([ diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index d2287cac2786bf..501b09a0c60657 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { AsyncClipboardStrategy, CommentModeController, CommentsModel, CommentsView, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, commands, findNodeOffsetById, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode, type LinkPresentationKind } from '@vscode/markdown-editor'; -import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; +import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorHostTransport, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; import { Disposable, autorun, observableValue } from '@vscode/observables'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; @@ -47,6 +47,68 @@ interface InitialState { readonly linkPresentationRules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]; } +class CodeBlockEditorHostTransport implements IframeEmbeddedEditorHostTransport { + readonly #listeners = new Set<(message: unknown) => void>(); + readonly #pendingMessages: unknown[] = []; + readonly #postMessage: (message: unknown) => void; + readonly #onDispose: () => void; + #activated = false; + #disposed = false; + + readonly onMessage: IframeEmbeddedEditorHostTransport['onMessage'] = (listener: (message: unknown) => void) => { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + this.#listeners.add(listener); + if (!this.#activated) { + this.#activated = true; + for (const message of this.#pendingMessages.splice(0)) { + listener(message); + } + } + return { dispose: () => this.#listeners.delete(listener) }; + }; + + constructor( + readonly runtimeId: string, + postMessage: (message: unknown) => void, + onDispose: () => void, + ) { + this.#postMessage = postMessage; + this.#onDispose = onDispose; + } + + sendMessage(message: unknown): void { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + this.#postMessage(message); + } + + acceptMessage(message: unknown): void { + if (this.#disposed) { + return; + } + if (!this.#activated) { + this.#pendingMessages.push(message); + return; + } + for (const listener of this.#listeners) { + listener(message); + } + } + + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#pendingMessages.length = 0; + this.#listeners.clear(); + this.#onDispose(); + } +} + class Editor extends Disposable { readonly model = new EditorModel(); isUpdatingFromExtension = false; @@ -54,7 +116,9 @@ class Editor extends Disposable { #mermaidCounter = 0; #codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] = []; #nextCodeBlockEditorRequestId = 1; + #nextCodeBlockEditorRuntimeId = 1; readonly #codeBlockEditorRequests = new Map void>(); + readonly #codeBlockEditorHostTransports = new Map(); #controller: EditorController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: VirtualizedIframeEmbeddedEditorFactory | undefined; @@ -126,6 +190,12 @@ class Editor extends Disposable { } break; } + case 'codeBlockEditorHostTransportMessage': { + if (typeof message.runtimeId === 'string') { + this.#codeBlockEditorHostTransports.get(message.runtimeId)?.acceptMessage(message.message); + } + break; + } case 'gutterMarkers': { const markers: GutterMarker[] = message.markers.map((marker: { start: number; endExclusive: number; type: GutterMarker['type'] }) => ({ range: OffsetRange.fromTo(marker.start, marker.endExclusive), @@ -168,6 +238,9 @@ class Editor extends Disposable { resolve(undefined); } this.#codeBlockEditorRequests.clear(); + for (const transport of Array.from(this.#codeBlockEditorHostTransports.values())) { + transport.dispose(); + } }, }); } @@ -179,6 +252,7 @@ class Editor extends Disposable { providers: this.#createIframeProviders(this.#codeBlockEditorProviders), scriptNonce, themeCss: () => `:root { ${document.documentElement.getAttribute('style') ?? ''} }`, + iframeBootstrapUrl: location.href, onAmbiguous: (language, providers) => this.#vscode.postMessage({ type: 'codeBlockEditorDiagnostic', message: `Ambiguous providers for ${language}: ${providers.map(provider => provider.id).join(', ')}`, @@ -393,12 +467,40 @@ class Editor extends Disposable { return definitions.map(definition => ({ id: definition.id, selector: definition.selector, + createHostTransport: runtimeKey => this.#createCodeBlockEditorHostTransport(definition.id, runtimeKey), resolve: definition.source.kind === 'static' ? async () => definition.source.kind === 'static' ? definition.source.descriptor : undefined : language => this.#resolveCodeBlockEditor(definition.id, language), })); } + #createCodeBlockEditorHostTransport(providerId: string, runtimeKey: string): CodeBlockEditorHostTransport { + const runtimeId = `${providerId}:${this.#nextCodeBlockEditorRuntimeId++}`; + const transport = new CodeBlockEditorHostTransport( + runtimeId, + message => this.#vscode.postMessage({ + type: 'codeBlockEditorHostTransportMessage', + runtimeId, + message, + }), + () => { + this.#codeBlockEditorHostTransports.delete(runtimeId); + this.#vscode.postMessage({ + type: 'disposeCodeBlockEditorHostTransport', + runtimeId, + }); + }, + ); + this.#codeBlockEditorHostTransports.set(runtimeId, transport); + this.#vscode.postMessage({ + type: 'createCodeBlockEditorHostTransport', + runtimeId, + providerId, + runtimeKey, + }); + return transport; + } + #resolveCodeBlockEditor(providerId: string, language: string): Promise { const requestId = this.#nextCodeBlockEditorRequestId++; return new Promise(resolve => { @@ -515,6 +617,10 @@ function readResolvedCodeBlockEditor(value: unknown): ResolvedIframeEmbeddedEdit const descriptor = value as Record; if ( typeof descriptor.html !== 'string' + || typeof descriptor.runtimeKey !== 'string' + || descriptor.runtimeKey.length === 0 + || (descriptor.resourceBaseUrl !== undefined && typeof descriptor.resourceBaseUrl !== 'string') + || (descriptor.hostTransport !== undefined && typeof descriptor.hostTransport !== 'boolean') || (descriptor.contentType !== 'text' && descriptor.contentType !== 'json') || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') || (descriptor.initialHeight !== undefined && (typeof descriptor.initialHeight !== 'number' || !Number.isFinite(descriptor.initialHeight) || descriptor.initialHeight <= 0)) diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 14795de9066d10..bca87cd3d18dd8 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-84", + "@vscode/markdown-editor": "^0.0.2-87", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -633,9 +633,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-84", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-84.tgz", - "integrity": "sha512-CD/FrfJTNnc3nVrdE6oVjHP+e12/4Ie84eaxSOSLkf7QhktkLhdrYFHz+tJ45jn9wGARMl41Bz0emnUSru80OA==", + "version": "0.0.2-87", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-87.tgz", + "integrity": "sha512-c1T5c2p2btf8NGVARkDN/qGezyZ25xCL/2KXqwsQeh9NmcH4bPZi2CtaeaLmQU2EyHS7WpdYKcIdwGMrJstLEg==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index ca96b6345b8467..998abf85286920 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1791,7 +1791,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-84", + "@vscode/markdown-editor": "^0.0.2-87", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", diff --git a/extensions/markdown-language-features/schemas/package.schema.json b/extensions/markdown-language-features/schemas/package.schema.json index 434e1b69d51b2e..dc8f89cb2bb577 100644 --- a/extensions/markdown-language-features/schemas/package.schema.json +++ b/extensions/markdown-language-features/schemas/package.schema.json @@ -69,7 +69,7 @@ }, "entrypoint": { "type": "string", - "description": "Extension-relative path to a self-contained HTML document" + "description": "Extension-relative path to the editor's HTML entrypoint. Relative scripts and assets are resolved from this file's directory" }, "contentType": { "type": "string", @@ -85,7 +85,7 @@ }, "markdown.codeBlockEditorProviders": { "type": "array", - "description": "Providers for self-contained HTML editors used by fenced code blocks in the Markdown editor", + "description": "Providers for HTML editors used by fenced code blocks in the Markdown editor", "items": { "type": "object", "additionalProperties": false, @@ -100,6 +100,12 @@ "minLength": 1, "description": "Identifier for this provider, unique within the extension" }, + "runtimeKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Opaque identity for the editor runtime template. Only compatible editors with equal runtime keys may reuse an iframe" + }, "selector": { "oneOf": [ { @@ -140,7 +146,7 @@ }, "entrypoint": { "type": "string", - "description": "Extension-relative path to a self-contained HTML document" + "description": "Extension-relative path to the editor's HTML entrypoint. Relative scripts and assets are resolved from this file's directory" } } }, diff --git a/extensions/markdown-language-features/src/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts index 0507ecd05cf234..d24b9108a042fe 100644 --- a/extensions/markdown-language-features/src/markdownExtensions.ts +++ b/extensions/markdown-language-features/src/markdownExtensions.ts @@ -48,6 +48,7 @@ export interface MarkdownCodeBlockEditorProvider { readonly providerId: string; readonly extension: vscode.Extension; readonly extensionVersion: string; + readonly runtimeKey?: string; readonly selector: MarkdownCodeBlockEditorSelector; readonly source: MarkdownCodeBlockEditorSource; readonly contentType: 'text' | 'json'; @@ -100,6 +101,7 @@ export namespace MarkdownContributions { && x.providerId === y.providerId && x.extension.id === y.extension.id && x.extensionVersion === y.extensionVersion + && x.runtimeKey === y.runtimeKey && selectorEqual(x.selector, y.selector) && sourceEqual(x.source, y.source) && x.contentType === y.contentType @@ -179,6 +181,7 @@ export namespace MarkdownContributions { typeof provider.id !== 'string' || !selector || !source + || (provider.runtimeKey !== undefined && (typeof provider.runtimeKey !== 'string' || provider.runtimeKey.length === 0 || provider.runtimeKey.length > 256)) || (provider.contentType !== undefined && provider.contentType !== 'text' && provider.contentType !== 'json') || (provider.initialHeight !== undefined && !isPositiveNumber(provider.initialHeight)) ) { @@ -189,6 +192,7 @@ export namespace MarkdownContributions { providerId: provider.id, extension, extensionVersion: typeof extension.packageJSON?.version === 'string' ? extension.packageJSON.version : '', + runtimeKey: provider.runtimeKey as string | undefined, selector, source, contentType: provider.contentType ?? 'text', diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index ff74f091048194..989d3ea4a3f2c9 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -28,6 +28,9 @@ interface CodeBlockEditorProviderDefinition { interface ResolvedCodeBlockEditor { readonly cacheKey?: string; readonly html: string; + readonly runtimeKey: string; + readonly resourceBaseUrl?: string; + readonly hostTransport?: boolean; readonly contentType: 'text' | 'json'; readonly initialHeight?: number; readonly sandbox?: MarkdownCodeBlockEditorSandbox; @@ -37,6 +40,10 @@ export interface MarkdownCodeBlockEditorApiV1 { getProvider(providerId: string): MarkdownCodeBlockEditorProviderApi | undefined; } +export interface MarkdownCodeBlockEditorApiV2 { + getProvider(providerId: string): MarkdownCodeBlockEditorProviderApi | undefined; +} + interface MarkdownCodeBlockEditorProviderApi { resolve( request: { @@ -46,18 +53,30 @@ interface MarkdownCodeBlockEditorProviderApi { }, token: vscode.CancellationToken, ): vscode.ProviderResult; + createHostTransport?( + transport: MarkdownCodeBlockEditorHostTransport, + token: vscode.CancellationToken, + ): vscode.ProviderResult; } interface ProviderResolvedCodeBlockEditor { readonly content: - | { readonly html: string; readonly uri?: undefined } + | { readonly html: string; readonly baseUri?: vscode.Uri; readonly uri?: undefined } | { readonly html?: undefined; readonly uri: vscode.Uri }; readonly contentType?: 'text' | 'json'; readonly cacheKey?: string; + readonly runtimeKey?: string; readonly initialHeight?: number; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } +interface MarkdownCodeBlockEditorHostTransport { + readonly runtimeKey: string; + readonly onDidReceiveMessage: vscode.Event; + readonly onDidDispose: vscode.Event; + sendMessage(message: unknown): void; +} + /** * Authenticates messages sent from the extension host to one Markdown editor webview. */ @@ -76,6 +95,66 @@ class AuthenticatedWebview { } } +class CodeBlockEditorHostTransportState implements vscode.Disposable { + readonly #onDidReceiveMessage = new vscode.EventEmitter(); + readonly #onDidDispose = new vscode.EventEmitter(); + readonly #pendingMessages: unknown[] = []; + #providerDisposable: vscode.Disposable | undefined; + #ready = false; + #disposed = false; + + readonly transport: MarkdownCodeBlockEditorHostTransport; + + constructor(runtimeKey: string, sendMessage: (message: unknown) => void) { + this.transport = Object.freeze({ + runtimeKey, + onDidReceiveMessage: this.#onDidReceiveMessage.event, + onDidDispose: this.#onDidDispose.event, + sendMessage: (message: unknown) => { + if (this.#disposed) { + throw new Error('Code block editor host transport is disposed'); + } + sendMessage(message); + }, + }); + } + + acceptMessage(message: unknown): void { + if (this.#disposed) { + return; + } + if (!this.#ready) { + this.#pendingMessages.push(message); + return; + } + this.#onDidReceiveMessage.fire(message); + } + + setReady(providerDisposable: vscode.Disposable | undefined): void { + if (this.#disposed) { + providerDisposable?.dispose(); + return; + } + this.#providerDisposable = providerDisposable; + this.#ready = true; + for (const message of this.#pendingMessages.splice(0)) { + this.#onDidReceiveMessage.fire(message); + } + } + + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#pendingMessages.length = 0; + this.#onDidDispose.fire(); + this.#providerDisposable?.dispose(); + this.#onDidReceiveMessage.dispose(); + this.#onDidDispose.dispose(); + } +} + /** * Experimental hybrid (WYSIWYG) Markdown editor backed by the * `@vscode/markdown-editor` component. The {@link vscode.TextDocument} remains @@ -167,16 +246,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } const webview = new AuthenticatedWebview(webviewPanel.webview); this.#webviewPanels.set(webviewPanel, webview); - const codeBlockEditorProviders = this.#loadCodeBlockEditorProviders(); + const codeBlockEditorProviders = this.#loadCodeBlockEditorProviders(webviewPanel.webview); this.#wireSingle(document, webviewPanel, originalDocument, codeBlockEditorProviders, webview); this.#configureWebview(document, webview); } #configureWebview(document: vscode.TextDocument, editorWebview: AuthenticatedWebview): void { const webview = editorWebview.webview; + const codeBlockEditorResourceRoots = vscode.workspace.isTrusted + ? this.#contributions.contributions.codeBlockEditorProviders.map(provider => provider.extension.extensionUri) + : []; webview.options = { enableScripts: true, - localResourceRoots: getMarkdownLocalResourceRoots(document.uri, [this.#mediaRoot], { + localResourceRoots: getMarkdownLocalResourceRoots(document.uri, [this.#mediaRoot, ...codeBlockEditorResourceRoots], { includeWorkspaceResources: vscode.workspace.isTrusted, }), }; @@ -196,6 +278,49 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT let codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] | undefined; let contributionUpdate = 0; const resolveCancellation = new vscode.CancellationTokenSource(); + const hostTransports = new Map(); + const disposeHostTransport = (runtimeId: string, expected?: CodeBlockEditorHostTransportState): void => { + const transport = hostTransports.get(runtimeId); + if (transport && (!expected || transport === expected)) { + hostTransports.delete(runtimeId); + transport.dispose(); + } + }; + const disposeHostTransports = (): void => { + for (const runtimeId of Array.from(hostTransports.keys())) { + disposeHostTransport(runtimeId); + } + }; + const createHostTransport = ( + runtimeId: string, + providerId: string, + runtimeKey: string, + ): void => { + disposeHostTransport(runtimeId); + const contribution = this.#contributions.contributions.codeBlockEditorProviders.find(candidate => candidate.id === providerId); + if (contribution?.source.kind !== 'exportApi' || contribution.source.apiVersion < 2 || !vscode.workspace.isTrusted) { + return; + } + const state = new CodeBlockEditorHostTransportState( + runtimeKey, + message => editorWebview.postMessage({ + type: 'codeBlockEditorHostTransportMessage', + runtimeId, + message, + }), + ); + hostTransports.set(runtimeId, state); + void this.#initializeCodeBlockEditorHostTransport(contribution, state, resolveCancellation.token).then(initialized => { + if (!initialized) { + disposeHostTransport(runtimeId, state); + } + }, error => { + if (!resolveCancellation.token.isCancellationRequested) { + this.#logger.trace('Markdown code block editor', `Provider ${providerId} failed to initialize a host transport`, error); + } + disposeHostTransport(runtimeId, state); + }); + }; const richLinks = new MarkdownEditorRichLinkController( document, this.#linkOpener, @@ -237,7 +362,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT ? this.#contributions.contributions.codeBlockEditorProviders.find(candidate => candidate.id === message.providerId) : undefined; const descriptor = provider && typeof message.language === 'string' - ? await this.#resolveCodeBlockEditor(provider, document.uri, message.language) + ? await this.#resolveCodeBlockEditor(provider, document.uri, message.language, editorWebview.webview) : undefined; if (resolveCancellation.token.isCancellationRequested) { break; @@ -250,6 +375,31 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT break; } + case 'createCodeBlockEditorHostTransport': { + if ( + typeof message.runtimeId === 'string' + && typeof message.providerId === 'string' + && typeof message.runtimeKey === 'string' + ) { + createHostTransport(message.runtimeId, message.providerId, message.runtimeKey); + } + break; + } + + case 'codeBlockEditorHostTransportMessage': { + if (typeof message.runtimeId === 'string') { + hostTransports.get(message.runtimeId)?.acceptMessage(message.message); + } + break; + } + + case 'disposeCodeBlockEditorHostTransport': { + if (typeof message.runtimeId === 'string') { + disposeHostTransport(message.runtimeId); + } + break; + } + case 'codeBlockEditorDiagnostic': { if (typeof message.message === 'string') { this.#logger.trace('Markdown code block editor', message.message); @@ -339,7 +489,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const comments = this.#wireComments(document, editorWebview); const onDidGrantWorkspaceTrust = vscode.workspace.onDidGrantWorkspaceTrust(() => { webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); + void refreshCodeBlockEditorProviders(true, true); }); const refreshCodeBlockEditorProviders = async (clearProviderApis: boolean, force: boolean): Promise => { const update = ++contributionUpdate; @@ -349,7 +501,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#resolvedCodeBlockEditors.clear(); this.#resolvedCodeBlockEditorResources.clear(); } - const updatedCodeBlockEditorProviders = await this.#loadCodeBlockEditorProviders(); + const updatedCodeBlockEditorProviders = await this.#loadCodeBlockEditorProviders(editorWebview.webview); if ( update !== contributionUpdate || (!force && codeBlockEditorProviders && codeBlockEditorDefinitionsEqual(codeBlockEditorProviders, updatedCodeBlockEditorProviders)) @@ -381,12 +533,14 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (event.affectsConfiguration('markdown.experimental.richLinks.enabled', document.uri)) { richLinks.updateTargets([]); webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); } }); const onDidChangeLinkPresentationRules = vscode.window.onDidChangeLinkPresentationRules(() => { richLinks.updateTargets([]); webviewReady = false; + disposeHostTransports(); this.#configureWebview(document, editorWebview); }); @@ -394,6 +548,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contributionUpdate++; resolveCancellation.cancel(); resolveCancellation.dispose(); + disposeHostTransports(); this.#webviewPanels.delete(webviewPanel); this.#focusedWebviewPanels.delete(webviewPanel); this.#updateEditorFocusContext(); @@ -429,7 +584,10 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT await vscode.commands.executeCommand('setContext', 'markdownEditorFocus', focused); } - async #loadCodeBlockEditorProviders(): Promise { + async #loadCodeBlockEditorProviders(webview: vscode.Webview): Promise { + if (!vscode.workspace.isTrusted) { + return []; + } const result: CodeBlockEditorProviderDefinition[] = []; for (const provider of this.#contributions.contributions.codeBlockEditorProviders) { if (provider.source.kind === 'exportApi') { @@ -453,6 +611,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT kind: 'static', descriptor: { html: new TextDecoder('utf-8', { fatal: true }).decode(bytes), + runtimeKey: provider.runtimeKey ?? `${provider.id}@${provider.extensionVersion}`, + resourceBaseUrl: getCodeBlockEditorResourceBaseUrl(webview, provider.source.resource), contentType: provider.contentType, initialHeight: provider.initialHeight, sandbox: provider.sandbox, @@ -470,6 +630,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contribution: MarkdownCodeBlockEditorProvider, documentUri: vscode.Uri, language: string, + webview: vscode.Webview, ): Promise { if (contribution.source.kind !== 'exportApi' || !vscode.workspace.isTrusted) { return undefined; @@ -477,7 +638,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const requestCacheKey = `${contribution.id}\0${documentUri.toString()}\0${language}`; let cached = this.#resolvedCodeBlockEditors.get(requestCacheKey); if (!cached) { - cached = this.#doResolveCodeBlockEditor(contribution, documentUri, language); + cached = this.#doResolveCodeBlockEditor(contribution, documentUri, language, webview); this.#resolvedCodeBlockEditors.set(requestCacheKey, cached); cached.then(result => { if (!result) { @@ -498,6 +659,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT contribution: MarkdownCodeBlockEditorProvider, documentUri: vscode.Uri, language: string, + webview: vscode.Webview, ): Promise { const cancellation = new vscode.CancellationTokenSource(); let timedOut = false; @@ -526,7 +688,13 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (value.content.uri !== undefined) { this.#resolvedCodeBlockEditorResources.add(value.content.uri.toString()); } - return await this.#readResolvedCodeBlockEditor(contribution, value); + return await this.#readResolvedCodeBlockEditor( + contribution, + value, + language, + webview, + typeof provider.createHostTransport === 'function', + ); }; const result = await Promise.race([operation(), cancelled]); if (timedOut) { @@ -545,16 +713,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } async #getCodeBlockEditorProvider(contribution: MarkdownCodeBlockEditorProvider): Promise { - if (contribution.source.kind !== 'exportApi' || !isSupportedMarkdownCodeBlockEditorApiVersion(contribution.source.apiVersion)) { + const source = contribution.source; + if (source.kind !== 'exportApi' || !isSupportedMarkdownCodeBlockEditorApiVersion(source.apiVersion)) { return undefined; } let cached = this.#providerApis.get(contribution.id); if (!cached) { cached = (async () => { const exports = await contribution.extension.activate(); - const api = getMarkdownCodeBlockEditorApiV1(exports); + const api = source.apiVersion === 1 + ? getMarkdownCodeBlockEditorApiV1(exports) + : getMarkdownCodeBlockEditorApiV2(exports); if (!api) { - this.#logger.trace('Markdown code block editor', `Extension ${contribution.extension.id} does not export markdownCodeBlockEditors.apiV1`); + this.#logger.trace('Markdown code block editor', `Extension ${contribution.extension.id} does not export markdownCodeBlockEditors.apiV${source.apiVersion}`); return undefined; } const provider = api.getProvider(contribution.providerId); @@ -572,14 +743,19 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT async #readResolvedCodeBlockEditor( contribution: MarkdownCodeBlockEditorProvider, value: ProviderResolvedCodeBlockEditor, + language: string, + webview: vscode.Webview, + hasHostTransport: boolean, ): Promise { if (!isProviderResolvedCodeBlockEditor(value)) { this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned an invalid descriptor`); return undefined; } let html: string; + let baseUri: vscode.Uri | undefined; if (value.content.html !== undefined) { html = value.content.html; + baseUri = value.content.baseUri; } else { if (!isAllowedCodeBlockEditorResource(value.content.uri, contribution.extension.extensionUri)) { this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned a resource outside its extension and the workspace`); @@ -587,16 +763,38 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } const bytes = await vscode.workspace.fs.readFile(value.content.uri); html = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + baseUri = value.content.uri; + } + if (baseUri && !isAllowedCodeBlockEditorResource(baseUri, contribution.extension.extensionUri)) { + this.#logger.trace('Markdown code block editor', `Provider ${contribution.id} returned a base URI outside its extension and the workspace`); + return undefined; } return { cacheKey: value.cacheKey, html, + runtimeKey: value.runtimeKey ?? contribution.runtimeKey ?? value.cacheKey ?? `${contribution.id}@${contribution.extensionVersion}:${language}`, + resourceBaseUrl: baseUri ? getCodeBlockEditorResourceBaseUrl(webview, baseUri, value.content.html === undefined) : undefined, + hostTransport: hasHostTransport && contribution.source.kind === 'exportApi' && contribution.source.apiVersion >= 2, contentType: value.contentType ?? contribution.contentType, initialHeight: value.initialHeight ?? contribution.initialHeight, sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; } + async #initializeCodeBlockEditorHostTransport( + contribution: MarkdownCodeBlockEditorProvider, + state: CodeBlockEditorHostTransportState, + token: vscode.CancellationToken, + ): Promise { + const provider = await this.#getCodeBlockEditorProvider(contribution); + if (!provider?.createHostTransport || token.isCancellationRequested) { + return false; + } + const disposable = await provider.createHostTransport(state.transport, token); + state.setReady(disposable ?? undefined); + return !token.isCancellationRequested; + } + #clearCodeBlockEditorCaches(): void { this.#providerApis.clear(); this.#resolvedCodeBlockEditors.clear(); @@ -779,7 +977,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT + content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; font-src ${webview.cspSource}; img-src ${webview.cspSource} https: data:; media-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' ${webview.cspSource}; worker-src ${webview.cspSource} blob:; connect-src ${webview.cspSource}; frame-src 'self';" /> @@ -812,6 +1010,9 @@ function codeBlockEditorDefinitionsEqual( function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCodeBlockEditor): boolean { return a.cacheKey === b.cacheKey && a.html === b.html + && a.runtimeKey === b.runtimeKey + && a.resourceBaseUrl === b.resourceBaseUrl + && a.hostTransport === b.hostTransport && a.contentType === b.contentType && a.initialHeight === b.initialHeight && a.sandbox?.forms === b.sandbox?.forms @@ -821,6 +1022,17 @@ function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCo } export function getMarkdownCodeBlockEditorApiV1(value: unknown): MarkdownCodeBlockEditorApiV1 | undefined { + return getMarkdownCodeBlockEditorApi(value, 1); +} + +export function getMarkdownCodeBlockEditorApiV2(value: unknown): MarkdownCodeBlockEditorApiV2 | undefined { + return getMarkdownCodeBlockEditorApi(value, 2); +} + +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1): MarkdownCodeBlockEditorApiV1 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 2): MarkdownCodeBlockEditorApiV2 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1 | 2): MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 | undefined; +function getMarkdownCodeBlockEditorApi(value: unknown, apiVersion: 1 | 2): MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 | undefined { if (!value || typeof value !== 'object') { return undefined; } @@ -828,15 +1040,15 @@ export function getMarkdownCodeBlockEditorApiV1(value: unknown): MarkdownCodeBlo if (!namespace || typeof namespace !== 'object') { return undefined; } - const api = (namespace as Record).apiV1; - return isMarkdownCodeBlockEditorApiV1(api) ? api : undefined; + const api = (namespace as Record)[`apiV${apiVersion}`]; + return isMarkdownCodeBlockEditorApi(api) ? api : undefined; } -export function isSupportedMarkdownCodeBlockEditorApiVersion(value: number): value is 1 { - return value === 1; +export function isSupportedMarkdownCodeBlockEditorApiVersion(value: number): value is 1 | 2 { + return value === 1 || value === 2; } -function isMarkdownCodeBlockEditorApiV1(value: unknown): value is MarkdownCodeBlockEditorApiV1 { +function isMarkdownCodeBlockEditorApi(value: unknown): value is MarkdownCodeBlockEditorApiV1 | MarkdownCodeBlockEditorApiV2 { return typeof value === 'object' && value !== null && typeof (value as Record).getProvider === 'function'; @@ -856,6 +1068,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes if ( (descriptor.contentType !== undefined && descriptor.contentType !== 'text' && descriptor.contentType !== 'json') || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') + || (descriptor.runtimeKey !== undefined && (typeof descriptor.runtimeKey !== 'string' || descriptor.runtimeKey.length === 0 || descriptor.runtimeKey.length > 256)) || (descriptor.initialHeight !== undefined && (!Number.isFinite(descriptor.initialHeight) || (descriptor.initialHeight as number) <= 0)) || !isSandbox(descriptor.sandbox) || !descriptor.content @@ -864,7 +1077,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes return false; } const content = descriptor.content as Record; - return (typeof content.html === 'string' && content.uri === undefined) + return (typeof content.html === 'string' && content.uri === undefined && (content.baseUri === undefined || content.baseUri instanceof vscode.Uri)) || (content.html === undefined && content.uri instanceof vscode.Uri); } @@ -896,6 +1109,7 @@ function isAllowedCodeBlockEditorResource(resource: vscode.Uri, extensionUri: vs if (vscode.workspace.getWorkspaceFolder(resource)) { return true; } + if (resource.scheme !== extensionUri.scheme || resource.authority !== extensionUri.authority) { return false; } @@ -906,6 +1120,13 @@ function isAllowedCodeBlockEditorResource(resource: vscode.Uri, extensionUri: vs return resourcePath === extensionPath || resourcePath.startsWith(extensionPrefix); } +function getCodeBlockEditorResourceBaseUrl(webview: vscode.Webview, resource: vscode.Uri, resourceIsEntrypoint = true): string { + const path = resourceIsEntrypoint + ? resource.path.slice(0, resource.path.lastIndexOf('/') + 1) + : resource.path.endsWith('/') ? resource.path : `${resource.path}/`; + return webview.asWebviewUri(resource.with({ path, query: '', fragment: '' })).toString(); +} + function getNonce(): string { let text = ''; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts index 672c608d41fd12..b7dce252bae913 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; import { MarkdownContributions } from '../markdownExtensions'; -import { getMarkdownCodeBlockEditorApiV1, isSupportedMarkdownCodeBlockEditorApiVersion, lineRangesToGutterMarkers } from '../preview/markdownEditorProvider'; +import { getMarkdownCodeBlockEditorApiV1, getMarkdownCodeBlockEditorApiV2, isSupportedMarkdownCodeBlockEditorApiVersion, lineRangesToGutterMarkers } from '../preview/markdownEditorProvider'; import { encodeWebviewInitialState } from '../preview/webviewInitialState'; suite('Markdown editor diff', () => { @@ -79,13 +79,32 @@ suite('Markdown code block editor API versioning', () => { }), undefined); }); - test('only advertises API version 1', () => { - assert.strictEqual(isSupportedMarkdownCodeBlockEditorApiVersion(1), true); - assert.strictEqual(isSupportedMarkdownCodeBlockEditorApiVersion(2), false); + test('accepts the namespaced V2 extension API', () => { + const apiV2 = { getProvider: () => undefined }; + assert.strictEqual(getMarkdownCodeBlockEditorApiV2({ + markdownCodeBlockEditors: { apiV2 }, + }), apiV2); + assert.strictEqual(getMarkdownCodeBlockEditorApiV2({ + markdownCodeBlockEditors: { apiV1: apiV2 }, + }), undefined); + }); + + test('advertises API versions 1 and 2', () => { + assert.deepStrictEqual( + [0, 1, 2, 3].map(isSupportedMarkdownCodeBlockEditorApiVersion), + [false, true, true, false], + ); + }); + + test('reads the optional runtime key', () => { + assert.strictEqual(readCodeBlockEditorProviders( + { kind: 'exportApi', apiVersion: 2 }, + 'shared-runtime', + )[0]?.runtimeKey, 'shared-runtime'); }); }); -function readCodeBlockEditorProviders(source: unknown) { +function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { const extension = { id: 'test.markdown-code-block-editor', extensionUri: vscode.Uri.file('/test/markdown-code-block-editor'), @@ -96,6 +115,7 @@ function readCodeBlockEditorProviders(source: unknown) { id: 'test', selector: { language: 'test' }, source, + runtimeKey, }], }, }, diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md b/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md new file mode 100644 index 00000000000000..a51a51bf8a842c --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-demo.md @@ -0,0 +1,20 @@ + +```checkbox-count +This code block is replaced by the extension-provided editor. +``` + + +# Checkbox count transport demo + +Edit or toggle these tasks. The code block editor below is updated by the +workspace extension through the Markdown editor's host transport. + +- [ ] First unchecked task +- [x] Completed task +- [ ] Second unchecked task + + +## More tasks + +* [ ] Third unchecked task +* [ ] Another completed task diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore new file mode 100644 index 00000000000000..a5ce37086b732c --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/.gitignore @@ -0,0 +1,2 @@ +editor/dist/ +dist/ diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md new file mode 100644 index 00000000000000..f1eee7435aa7b0 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/README.md @@ -0,0 +1,20 @@ +# Markdown checkbox-count demo extension + +This workspace extension demonstrates a Markdown code block editor whose UI is +loaded from external HTML, CSS, and TypeScript modules. The iframe communicates +with this extension through `WebEditorClient.hostTransport`. + +From the VS Code repository: + +1. Build `@vscode/markdown-editor` and `@vscode/web-editors` in the adjacent + `vscode-packages` checkout. +2. Run `npm install` and `npm run build` in this folder, then run `npm install` + in `extensions/markdown-language-features`. +3. Start the **Markdown Code Block Editor Demo** launch configuration. +4. Open `checkbox-count-demo.md` with the Markdown editor. +5. Toggle or edit task-list checkboxes. The count rendered by the code block + editor updates through the extension host. + +The build bundles `@vscode/web-editors` and its transitive dependencies into +the iframe entrypoint. The formatter remains a separate generated chunk so the +demo also exercises relative dynamic imports. diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts new file mode 100644 index 00000000000000..9fc41b7da5a813 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/formatCheckboxLabel.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function formatTaskProgressLabel(checked: number, total: number): string { + return `${checked}/${total} tasks done`; +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html new file mode 100644 index 00000000000000..161cd73c2c6e9d --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/index.html @@ -0,0 +1,24 @@ + + + + + + + + Task Progress + + +
+
+ Loading task progress... +
+
+
+
+
+ + + diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts new file mode 100644 index 00000000000000..622b89eb8a97e0 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/main.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WebEditorClient } from '../node_modules/@vscode/web-editors/dist/index.js'; + +interface TaskProgressMessage { + readonly type: 'taskProgress'; + readonly checked: number; + readonly total: number; +} + +const mainElement = getElement('main'); +const progressElement = getElement('#progress'); +const progressLabelElement = getElement('#progress-label'); +const client = await WebEditorClient.connect({ connection: 'windowParent' }); +let reportedHeight: number | undefined; +let progressMessageVersion = 0; + +const reportSize = () => { + const mainHeight = Math.ceil(mainElement.getBoundingClientRect().height); + if (mainHeight === reportedHeight) { + return; + } + reportedHeight = mainHeight; + console.log('[checkbox-count] reporting iframe size', { + height: mainHeight, + documentScrollHeight: document.documentElement.scrollHeight, + bodyScrollHeight: document.body.scrollHeight, + mainHeight: mainElement.getBoundingClientRect().height, + }); + client.reportSize(mainHeight); +}; +const resizeObserver = new ResizeObserver(reportSize); +resizeObserver.observe(mainElement); +requestAnimationFrame(reportSize); + +if (!client.hostTransport) { + progressLabelElement.textContent = 'Host transport unavailable'; +} else { + client.hostTransport.onMessage(async message => { + if (!isTaskProgressMessage(message)) { + return; + } + const messageVersion = ++progressMessageVersion; + const { formatTaskProgressLabel } = await import('./formatCheckboxLabel.js'); + if (messageVersion !== progressMessageVersion) { + return; + } + const progressMaximum = Math.max(message.total, 1); + progressElement.setAttribute('aria-valuemax', progressMaximum); + progressElement.setAttribute('aria-valuenow', message.checked); + progressElement.style.setProperty('--task-progress-ratio', message.checked / progressMaximum); + progressLabelElement.textContent = formatTaskProgressLabel(message.checked, message.total); + reportSize(); + }); + client.hostTransport.sendMessage({ type: 'ready' }); +} + +window.addEventListener('beforeunload', () => { + resizeObserver.disconnect(); + client.dispose(); +}, { once: true }); + +function getElement(selector: string): T { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Missing required element: ${selector}`); + } + return element; +} + +function isTaskProgressMessage(message: unknown): message is TaskProgressMessage { + return typeof message === 'object' + && message !== null + && 'type' in message + && message.type === 'taskProgress' + && 'checked' in message + && typeof message.checked === 'number' + && Number.isInteger(message.checked) + && 'total' in message + && typeof message.total === 'number' + && Number.isInteger(message.total) + && message.checked >= 0 + && message.total >= 0 + && message.checked <= message.total; +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css new file mode 100644 index 00000000000000..3701c977467864 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/editor/style.css @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +:root { + color-scheme: light dark; +} + +html, +body { + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); +} + +body { + margin: 0; +} + +main { + display: grid; + gap: var(--vscode-spacing-size80); + box-sizing: border-box; + padding: var(--vscode-spacing-size120); + border-left: var(--vscode-strokeThickness) solid var(--vscode-textLink-foreground); + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); + font-family: var(--vscode-font-family); +} + +.task-progress-summary { + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.task-progress { + --task-progress-ratio: 0; + + width: 100%; + height: var(--vscode-spacing-size80); + overflow: hidden; + border-radius: var(--vscode-cornerRadius-xSmall); + background: var(--vscode-editor-background); +} + +.task-progress-value { + width: 100%; + height: 100%; + transform: scaleX(var(--task-progress-ratio)); + transform-origin: left; + background: var(--vscode-progressBar-background); +} + +@media (prefers-reduced-motion: no-preference) { + .task-progress-value { + transition: transform 160ms ease-out; + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json new file mode 100644 index 00000000000000..7408f83c6142ce --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package-lock.json @@ -0,0 +1,579 @@ +{ + "name": "markdown-checkbox-count-demo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "markdown-checkbox-count-demo", + "version": "0.0.1", + "dependencies": { + "@vscode/web-editors": "^0.0.2-41" + }, + "devDependencies": { + "esbuild": "0.27.2" + }, + "engines": { + "vscode": "^1.109.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hpke/common": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", + "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@hpke/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", + "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@vscode/hubrpc": { + "version": "0.0.2-15", + "resolved": "https://registry.npmjs.org/@vscode/hubrpc/-/hubrpc-0.0.2-15.tgz", + "integrity": "sha512-eN520Qs/0UmI5l/nEWYLqQrYfx9JX7zZ116m13YTs/8lK55iq+DLeM6GpMWWWf8TE4+OMi8hDabkl/FYRp9XPQ==", + "license": "MIT", + "dependencies": { + "@hpke/core": "^1.9.0", + "ws": "^8.18.0" + }, + "peerDependencies": { + "zod": "^4.4.3" + } + }, + "node_modules/@vscode/web-editors": { + "version": "0.0.2-41", + "resolved": "https://registry.npmjs.org/@vscode/web-editors/-/web-editors-0.0.2-41.tgz", + "integrity": "sha512-HAaDy/gdiSk0Yp38lpZ1Je1Cd7NcWuq1W+aUxwRO/JlOijmsJuZXukAdlIL78hLb0eT+Awrr8VGseY42FUT8rA==", + "license": "MIT", + "dependencies": { + "@vscode/hubrpc": "next" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json new file mode 100644 index 00000000000000..04fc29cf5cd265 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/package.json @@ -0,0 +1,46 @@ +{ + "name": "markdown-checkbox-count-demo", + "displayName": "Markdown Checkbox Count Demo", + "description": "Demonstrates a Markdown code block editor communicating with its extension host.", + "version": "0.0.1", + "publisher": "vscode-samples", + "private": true, + "engines": { + "vscode": "^1.109.0" + }, + "main": "./dist/extension.js", + "scripts": { + "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js && esbuild editor/main.ts --bundle --format=esm --splitting --outdir=editor/dist --entry-names=[name] --chunk-names=chunks/[name]-[hash]" + }, + "activationEvents": [ + "onStartupFinished" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false + } + }, + "contributes": { + "markdown.codeBlockEditorProviders": [ + { + "id": "checkboxCount", + "runtimeKey": "checkbox-count-v1", + "selector": { + "language": "checkbox-count" + }, + "source": { + "kind": "exportApi", + "apiVersion": 2 + }, + "contentType": "text", + "initialHeight": 84 + } + ] + }, + "dependencies": { + "@vscode/web-editors": "^0.0.2-41" + }, + "devDependencies": { + "esbuild": "0.27.2" + } +} diff --git a/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts new file mode 100644 index 00000000000000..159a9d87e78d27 --- /dev/null +++ b/extensions/markdown-language-features/test-workspace/checkbox-count-extension/src/extension.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +const providerId = 'checkboxCount'; +const runtimeUris = new Map(); +const runtimeKeys = new Map(); +let nextRuntimeId = 1; + +interface MarkdownCodeBlockEditorResolveRequest { + readonly documentUri: vscode.Uri; +} + +interface MarkdownCodeBlockEditorHostTransport { + readonly runtimeKey: string; + sendMessage(message: TaskProgressMessage): void; + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable; + onDidDispose(listener: () => void): vscode.Disposable; +} + +interface TaskProgressMessage { + readonly type: 'taskProgress'; + readonly checked: number; + readonly total: number; +} + +export function activate(context: vscode.ExtensionContext) { + const provider = { + async resolve(request: MarkdownCodeBlockEditorResolveRequest) { + const documentKey = request.documentUri.toString(); + let runtimeKey = runtimeKeys.get(documentKey); + if (!runtimeKey) { + runtimeKey = `checkbox-count:${nextRuntimeId++}`; + runtimeKeys.set(documentKey, runtimeKey); + runtimeUris.set(runtimeKey, request.documentUri); + } + + return { + content: { + uri: vscode.Uri.joinPath(context.extensionUri, 'editor', 'index.html'), + }, + runtimeKey, + contentType: 'text', + initialHeight: 84, + }; + }, + + async createHostTransport(transport: MarkdownCodeBlockEditorHostTransport, token: vscode.CancellationToken) { + const documentUri = runtimeUris.get(transport.runtimeKey); + if (!documentUri) { + throw new Error(`Unknown checkbox-count runtime: ${transport.runtimeKey}`); + } + + const document = await vscode.workspace.openTextDocument(documentUri); + if (token.isCancellationRequested) { + return; + } + + const update = () => { + const progress = getTaskProgress(document.getText()); + transport.sendMessage({ + type: 'taskProgress', + checked: progress.checked, + total: progress.total, + }); + }; + + const documentListener = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() === document.uri.toString()) { + update(); + } + }); + const messageListener = transport.onDidReceiveMessage(message => { + if (isReadyMessage(message)) { + update(); + } + }); + const disposeListener = transport.onDidDispose(() => { + console.log(`Disposed checkbox-count runtime ${transport.runtimeKey}`); + }); + return vscode.Disposable.from(documentListener, messageListener, disposeListener); + }, + }; + + return { + markdownCodeBlockEditors: { + apiV2: { + getProvider(id) { + return id === providerId ? provider : undefined; + }, + }, + }, + }; +} + +function isReadyMessage(message: unknown): message is { readonly type: 'ready' } { + return typeof message === 'object' && message !== null && 'type' in message && message.type === 'ready'; +} + +function getTaskProgress(text: string) { + const tasks = Array.from(text.matchAll(/^\s*[-*+]\s+\[(?[ x])\]/gim)); + return { + checked: tasks.filter(task => task.groups?.state.toLowerCase() === 'x').length, + total: tasks.length, + }; +} diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index cefcef5a2ed2bb..efedffd0a6b224 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -91,14 +91,25 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.match(output, /Page ID:/, `Expected output to contain "Page ID:", got: ${output}`); }); - (vscode.env.remoteName ? test.skip : test)('Agent storage is shared between API and tool pages and isolated from persistent storage', async function () { + (vscode.env.remoteName ? test.skip : test)('Agent storage is shared, filtered, and isolated from persistent storage', async function () { this.timeout(60_000); const token = `${Date.now()}-${Math.random()}`; let agentReceivedCookie: string | undefined; let globalReceivedCookie: string | undefined; let workspaceReceivedCookie: string | undefined; - const server = http.createServer((request, response) => { + let agentProbeReceived = false; + let workspaceProbeReceived = false; + const server = http.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, resolve); + }); + + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const port = address.port; + server.on('request', (request, response) => { if (request.url === '/set-global') { response.setHeader('Set-Cookie', `vscode-browser-global-smoke=${token}; Path=/; SameSite=Lax`); response.end('global-cookie-set'); @@ -107,13 +118,13 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { if (request.url === '/set-workspace') { response.setHeader('Set-Cookie', `vscode-browser-workspace-smoke=${token}; Path=/; SameSite=Lax`); - response.end('workspace-cookie-set'); + response.end(`workspace-cookie-set`); return; } if (request.url === '/set-agent') { response.setHeader('Set-Cookie', `vscode-browser-agent-smoke=${token}; Path=/; SameSite=Lax`); - response.end('agent-cookie-set'); + response.end(`agent-cookie-set`); return; } @@ -135,35 +146,44 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { return; } - response.end('unexpected-request'); - }); + if (request.url === '/agent-probe') { + agentProbeReceived = true; + response.end(); + return; + } - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); + if (request.url === '/workspace-probe') { + workspaceProbeReceived = true; + response.end(); + return; + } - const address = server.address(); - assert.ok(address && typeof address !== 'string'); + response.end('unexpected-request'); + }); const browserConfig = vscode.workspace.getConfiguration('workbench.browser'); + const agentConfig = vscode.workspace.getConfiguration('chat.agent'); try { + await agentConfig.update('allowedNetworkDomains', ['*'], vscode.ConfigurationTarget.Global); + await agentConfig.update('deniedNetworkDomains', ['localhost'], vscode.ConfigurationTarget.Global); + await agentConfig.update('networkFilter', true, vscode.ConfigurationTarget.Global); + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); - const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-global`); + const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-global`); for (let i = 0; i < 100 && !globalSetTab.title.startsWith('global-cookie-set'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(globalSetTab.title.startsWith('global-cookie-set'), `Expected Global page to load, got title "${globalSetTab.title}"`); await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); - const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-workspace`); - for (let i = 0; i < 100 && !workspaceSetTab.title.startsWith('workspace-cookie-set'); i++) { + const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-workspace`); + for (let i = 0; i < 100 && (!workspaceSetTab.title.startsWith('workspace-cookie-set') || !workspaceProbeReceived); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(workspaceSetTab.title.startsWith('workspace-cookie-set'), `Expected Workspace page to load, got title "${workspaceSetTab.title}"`); await browserConfig.update('dataStorage', 'agent', vscode.ConfigurationTarget.Global); - const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-agent`); + const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-agent`); for (let i = 0; i < 100 && !agentSetTab.title.startsWith('agent-cookie-set'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); @@ -171,18 +191,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.ok(agentSetTab.title.startsWith('agent-cookie-set'), `Expected Agent page to load, got title "${agentSetTab.title}"`); const output = await invokeTool('open_browser_page', { - url: `http://127.0.0.1:${address.port}/check-agent`, + url: `http://127.0.0.1:${port}/check-agent`, forceNew: true, }); await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); - const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-global`); + const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-global`); for (let i = 0; i < 100 && !globalCheckTab.title.startsWith('global-cookie-checked'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); - const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-workspace`); + const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-workspace`); for (let i = 0; i < 100 && !workspaceCheckTab.title.startsWith('workspace-cookie-checked'); i++) { await new Promise(resolve => setTimeout(resolve, 50)); } @@ -192,26 +212,33 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { agentSharedCookie: agentReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, agentReceivedGlobalCookie: agentReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, agentReceivedWorkspaceCookie: agentReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + agentBlockedRequest: !agentProbeReceived, globalLoaded: globalCheckTab.title.startsWith('global-cookie-checked'), globalSharedCookie: globalReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, globalReceivedAgentCookie: globalReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, workspaceLoaded: workspaceCheckTab.title.startsWith('workspace-cookie-checked'), workspaceSharedCookie: workspaceReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, workspaceReceivedAgentCookie: workspaceReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + workspaceAllowedRequest: workspaceProbeReceived, }, { opened: true, agentSharedCookie: true, agentReceivedGlobalCookie: false, agentReceivedWorkspaceCookie: false, + agentBlockedRequest: true, globalLoaded: true, globalSharedCookie: true, globalReceivedAgentCookie: false, workspaceLoaded: true, workspaceSharedCookie: true, workspaceReceivedAgentCookie: false, + workspaceAllowedRequest: true, }); } finally { await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('networkFilter', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + await agentConfig.update('deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } }); diff --git a/package-lock.json b/package-lock.json index 0a585ac9ad0c34..88da7f3b3ca9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -33,7 +33,7 @@ "@vscode/native-watchdog": "^1.4.6", "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -1155,9 +1155,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-2.tgz", + "integrity": "sha512-ntRvwGdZbZJjOvdV3LVBQ7z69W19DO4MWe/dh6pP8kYeumbzC4udVj7mCtoSwBOs0hwXirhXY+BZwqSJUp5FLQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1166,20 +1166,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-2", + "@github/copilot-darwin-x64": "1.0.83-2", + "@github/copilot-linux-arm64": "1.0.83-2", + "@github/copilot-linux-x64": "1.0.83-2", + "@github/copilot-linuxmusl-arm64": "1.0.83-2", + "@github/copilot-linuxmusl-x64": "1.0.83-2", + "@github/copilot-win32-arm64": "1.0.83-2", + "@github/copilot-win32-x64": "1.0.83-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-2.tgz", + "integrity": "sha512-RBjF/zTJe+gp9PsthYtIHF36+y3A3Zv3w/2FAa9nHF6ople1+lYoQ0OC1z6tWQSpBx3n/fSkNt0ebMmfhWlQyw==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-2.tgz", + "integrity": "sha512-GzaarluCiHUA4yrYxWIFEklwPLTYjxGvLyYjI0wW5IPseT7GLFjN7AkDGRU4Ny/mxTj1MbUEsJAWVtiNy5EUHg==", "cpu": [ "x64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-2.tgz", + "integrity": "sha512-iGD5Pc7vnzrAjqTK/BrI8MDtMqXyxUe7P6u8TiGt+4DzKe3v2myT6eFQ+z/JJOHNpiu6LUpTDzwS8WttZqqJpA==", "cpu": [ "arm64" ], @@ -1228,9 +1228,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-2.tgz", + "integrity": "sha512-jOQwz075vRWat+RJiMNtD+7vm4DQ83Dvw4iakpNkLfqWMPXlliVr/tqL+SffeSo87LnhOKobZOJOjELS27drdg==", "cpu": [ "x64" ], @@ -1247,9 +1247,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-2.tgz", + "integrity": "sha512-6/+ByEBEV/vDofrCymaKrEZ5/p+TVHQfgWu8ywvWEPALfxj/iHpAF3grM2bj7uED4C19LTSarHNCkCqdrAUzIQ==", "cpu": [ "arm64" ], @@ -1266,9 +1266,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-2.tgz", + "integrity": "sha512-8dbiPUHaDemDibLfKXDe5xublJxt6fOht4yYDk/ikmOGAVezBUwRVO27PchXQBwGjP2PVNAASkv2WB4Ubw72lA==", "cpu": [ "x64" ], @@ -1300,9 +1300,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-2.tgz", + "integrity": "sha512-ZE1iUXlJSnNIH+VoRG96emy2e7jMJm85/pcKCrgLvqhDAcQSqiqLVv3OJp2VX2IcDxToOpiaGccU8Ab9zHQqkQ==", "cpu": [ "arm64" ], @@ -1316,9 +1316,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-2.tgz", + "integrity": "sha512-qfturLon+1oaWqSaXCISL6BEf5K+ijJFl0m/OZwstWYSlIlaE2+iOz5LcRukoxm25OG1JZDHQFp1k2PY2A+LbA==", "cpu": [ "x64" ], @@ -4964,9 +4964,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.44.0.tgz", - "integrity": "sha512-1vv0uJrIGxS89C+0gPmgNuOcw+Pjw0h7y0U3/l7pfwuiDq2Ua3evUVAkDj86v/Mn+UuFn20MWA04YyZ4huJcOA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.45.0.tgz", + "integrity": "sha512-rSR81pniNECvd3Zr1sq0VqYL7H+02dT723sEexpoweWi9mMklwLoapHTkwK6nAtxhjXNagRkT7l2GWuripXD7Q==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 46547033cb9b6c..8ad14851ea7c90 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "core-ci": "npm run gulp core-ci", "extensions-ci": "npm run gulp extensions-ci", "perf": "node scripts/code-perf.js", + "perf:css": "node .github/skills/auto-perf-optimize/scripts/workbench-css-performance.mts", "perf:chat": "node scripts/chat-simulation/test-chat-perf-regression.js", "perf:chat-leak": "node scripts/chat-simulation/test-chat-mem-leaks.js", "copilot:setup": "npm --prefix extensions/copilot run setup", @@ -101,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -122,7 +123,7 @@ "@vscode/native-watchdog": "^1.4.6", "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/remote/package-lock.json b/remote/package-lock.json index a61dff0c49c323..a17926c6a37af7 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -19,7 +19,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -61,9 +61,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-2.tgz", + "integrity": "sha512-ntRvwGdZbZJjOvdV3LVBQ7z69W19DO4MWe/dh6pP8kYeumbzC4udVj7mCtoSwBOs0hwXirhXY+BZwqSJUp5FLQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -72,20 +72,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-2", + "@github/copilot-darwin-x64": "1.0.83-2", + "@github/copilot-linux-arm64": "1.0.83-2", + "@github/copilot-linux-x64": "1.0.83-2", + "@github/copilot-linuxmusl-arm64": "1.0.83-2", + "@github/copilot-linuxmusl-x64": "1.0.83-2", + "@github/copilot-win32-arm64": "1.0.83-2", + "@github/copilot-win32-x64": "1.0.83-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-2.tgz", + "integrity": "sha512-RBjF/zTJe+gp9PsthYtIHF36+y3A3Zv3w/2FAa9nHF6ople1+lYoQ0OC1z6tWQSpBx3n/fSkNt0ebMmfhWlQyw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-2.tgz", + "integrity": "sha512-GzaarluCiHUA4yrYxWIFEklwPLTYjxGvLyYjI0wW5IPseT7GLFjN7AkDGRU4Ny/mxTj1MbUEsJAWVtiNy5EUHg==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-2.tgz", + "integrity": "sha512-iGD5Pc7vnzrAjqTK/BrI8MDtMqXyxUe7P6u8TiGt+4DzKe3v2myT6eFQ+z/JJOHNpiu6LUpTDzwS8WttZqqJpA==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-2.tgz", + "integrity": "sha512-jOQwz075vRWat+RJiMNtD+7vm4DQ83Dvw4iakpNkLfqWMPXlliVr/tqL+SffeSo87LnhOKobZOJOjELS27drdg==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-2.tgz", + "integrity": "sha512-6/+ByEBEV/vDofrCymaKrEZ5/p+TVHQfgWu8ywvWEPALfxj/iHpAF3grM2bj7uED4C19LTSarHNCkCqdrAUzIQ==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-2.tgz", + "integrity": "sha512-8dbiPUHaDemDibLfKXDe5xublJxt6fOht4yYDk/ikmOGAVezBUwRVO27PchXQBwGjP2PVNAASkv2WB4Ubw72lA==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-2.tgz", + "integrity": "sha512-ZE1iUXlJSnNIH+VoRG96emy2e7jMJm85/pcKCrgLvqhDAcQSqiqLVv3OJp2VX2IcDxToOpiaGccU8Ab9zHQqkQ==", "cpu": [ "arm64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-2.tgz", + "integrity": "sha512-qfturLon+1oaWqSaXCISL6BEf5K+ijJFl0m/OZwstWYSlIlaE2+iOz5LcRukoxm25OG1JZDHQFp1k2PY2A+LbA==", "cpu": [ "x64" ], @@ -910,9 +910,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.44.0.tgz", - "integrity": "sha512-1vv0uJrIGxS89C+0gPmgNuOcw+Pjw0h7y0U3/l7pfwuiDq2Ua3evUVAkDj86v/Mn+UuFn20MWA04YyZ4huJcOA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.45.0.tgz", + "integrity": "sha512-rSR81pniNECvd3Zr1sq0VqYL7H+02dT723sEexpoweWi9mMklwLoapHTkwK6nAtxhjXNagRkT7l2GWuripXD7Q==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index af7d55252059c3..309557bc0b3c9c 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "1.0.83-0", + "@github/copilot": "1.0.83-2", "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -14,7 +14,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.44.0", + "@vscode/proxy-agent": "^0.45.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index 44e683fe5dce4a..fb08a7df208f0a 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, globalTransaction, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -15,6 +16,7 @@ import { IContextKeyService, type IScopedContextKeyService } from '../../../../p import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { observableCodeEditor } from '../../observableCodeEditor.js'; @@ -24,6 +26,8 @@ import { ActionRunnerWithContext } from './utils.js'; import { IVirtualizedItemBindingContext, VirtualizedItemBinding, VirtualizedItemTemplate } from './virtualizedItemManager.js'; import { IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from './workbenchUIElementFactory.js'; +export const binaryFilePlaceholderContentHeight = 100; + export class DiffEditorItemTemplate extends VirtualizedItemTemplate { private readonly _viewModel; @@ -46,11 +50,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; this.editor = this._register(this._instantiationService.createInstance(DiffEditorWidget, this._elements.editor, { @@ -125,7 +138,28 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader)); + this.isBinaryFilePlaceholderFocused = observableValue(this, false); + const binaryFilePlaceholderFocus = this._register(trackFocus(this._elements.binaryFilePlaceholder)); + this._register(binaryFilePlaceholderFocus.onDidFocus(() => this.isBinaryFilePlaceholderFocused.set(true, undefined))); + this._register(binaryFilePlaceholderFocus.onDidBlur(() => this.isBinaryFilePlaceholderFocused.set(false, undefined))); + this.isFocused = derived(this, reader => + this.isModifedFocused.read(reader) + || this.isOriginalFocused.read(reader) + || this.isBinaryFilePlaceholderFocused.read(reader) + ); + this._elements.binaryFilePlaceholder.tabIndex = 0; + if (this._workbenchUIElementFactory.openDiffEditor) { + this._openBinaryDiffButton = this._register(new Button(this._elements.binaryFilePlaceholderActions, { ...defaultButtonStyles, secondary: true })); + this._openBinaryDiffButton.label = localize('openBinaryDiff', "Open Diff"); + this._register(this._openBinaryDiffButton.onDidClick(() => { + const item = this._viewModel.get(); + if (item?.originalUri && item.modifiedUri) { + this._workbenchUIElementFactory.openDiffEditor?.(item.originalUri, item.modifiedUri); + } + })); + } else { + this._openBinaryDiffButton = undefined; + } this._resourceLabel = this._workbenchUIElementFactory.createResourceLabel ? this._register(this._workbenchUIElementFactory.createResourceLabel(this._elements.primaryPath, MultiDiffEditorItemLabelKind.Primary)) : undefined; @@ -202,7 +236,13 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { const collapsed = this._collapsed.read(reader); - this._elements.editor.style.display = collapsed ? 'none' : 'block'; + const item = this._viewModel.read(reader); + const isBinary = item?.isBinary === true; + const canOpenDiff = !!(item?.originalUri && item.modifiedUri && this._openBinaryDiffButton); + this._elements.editor.style.display = collapsed || isBinary ? 'none' : 'block'; + this._elements.binaryFilePlaceholder.style.display = !collapsed && isBinary ? 'grid' : 'none'; + this._elements.binaryFilePlaceholder.tabIndex = canOpenDiff ? -1 : 0; + this._elements.binaryFilePlaceholderActions.style.display = canOpenDiff ? '' : 'none'; if (this._workbenchUIElementFactory.headerClickToCollapse) { this._elements.header.setAttribute('aria-expanded', String(!collapsed)); } @@ -224,7 +264,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { + if (item.isBinary) { + return; + } const viewModel = item.diffEditorViewModel; if (!viewModel.isDiffUpToDate.read(reader)) { return; @@ -470,6 +515,15 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; } +/** + * A resource participating on one side of a document diff. + */ +export class DiffItemSource { + constructor( + public readonly uri: URI, + public readonly textModel: ITextModel | undefined, + ) { } +} + export interface IDocumentDiffItem { /** * undefined if the file was created. */ - readonly original: ITextModel | undefined; + readonly original: DiffItemSource | undefined; /** * undefined if the file was deleted. */ - readonly modified: ITextModel | undefined; + readonly modified: DiffItemSource | undefined; readonly options?: IDiffEditorOptions; readonly onOptionsDidChange?: Event; readonly contextKeys?: Record; diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 06827aa7300199..7eccee465388c4 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -135,6 +135,11 @@ export class DocumentDiffItemViewModel extends Disposable { public get originalUri(): URI | undefined { return this.documentDiffItem.original?.uri; } public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; } + public get isBinary(): boolean { + const { original, modified } = this.documentDiffItem; + return (original !== undefined && original.textModel === undefined) + || (modified !== undefined && modified.textModel === undefined); + } public readonly isActive: IObservable = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); public readonly isFirst: IObservable = derived(this, reader => this._editorViewModel.items.read(reader)[0] === this); @@ -188,8 +193,8 @@ export class DocumentDiffItemViewModel extends Disposable { } const diffEditorViewModelStore = new DisposableStore(); - const originalTextModel = this.documentDiffItem.original ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); - const modifiedTextModel = this.documentDiffItem.modified ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); + const originalTextModel = this.documentDiffItem.original?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); + const modifiedTextModel = this.documentDiffItem.modified?.textModel ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); diffEditorViewModelStore.add(this._documentDiffItemRef.createNewRef(this)); this.diffEditorViewModelRef = this._register(RefCounted.createWithDisposable( diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index befafc3fcf06d4..08a7507a3f5047 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -127,6 +127,10 @@ export class MultiDiffEditorWidget extends Disposable { public readonly onDidChangeActiveControl = Event.fromObservableLight(this._activeControl); + public focus(): boolean { + return this._widgetImpl.get().focus(); + } + public getViewState(): IMultiDiffEditorViewState { return this._widgetImpl.get().getViewState(); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index cdf7d096bc0b60..89e7b9cad70b1f 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -23,7 +23,7 @@ import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { CompressedVirtualizedScrollView, ICompressedVirtualizedScrollItem, ICompressedVirtualizedScrollItemContext } from './compressedVirtualizedScrollView.js'; import { ICompressedVirtualizedScrollLayout } from './compressedVirtualizedScrollLayout.js'; -import { DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; +import { binaryFilePlaceholderContentHeight, DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; @@ -100,9 +100,18 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const manager = this._register(new VirtualizedItemManager(sourceItems, context, { getId: item => item, getTemplateId: () => 'diffEditor', - getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) - ? this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40 - : item.lastTemplateData.read(reader).expandedContentHeight), + getUnboundSize: item => derived(item, reader => { + const headerHeight = this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40; + if (item.collapsed.read(reader)) { + return headerHeight; + } + if (item.isBinary) { + return headerHeight + + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0) + + binaryFilePlaceholderContentHeight; + } + return item.lastTemplateData.read(reader).expandedContentHeight; + }), createTemplate: () => this._instantiationService.createInstance( DiffEditorItemTemplate, context.contentDomNode, @@ -516,30 +525,40 @@ export class MultiDiffEditorWidgetImpl extends Disposable { viewModel.activeDiffItem.setCache(target, undefined); if (!this._preserveFocusOnLoad) { - this._viewItemsInfo.get().getItem(target).template.get()?.editor.focus(); + this._viewItemsInfo.get().getItem(target).binding.get()?.focus(); } return true; } public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined { const item = this._viewItems.get().find(v => - v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString() - || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString() + v.viewModel.modifiedUri?.toString() === resource.toString() + || v.viewModel.originalUri?.toString() === resource.toString() ); return item?.viewModel.documentDiffItem; } + public focus(): boolean { + const activeDiffItem = this._viewModel.get()?.activeDiffItem.get(); + if (!activeDiffItem) { + return false; + } + const binding = this._viewItemsInfo.get().getItem(activeDiffItem).binding.get(); + binding?.focus(); + return binding !== undefined; + } + public tryGetCodeEditor(resource: URI): { diffEditor: IDiffEditor; editor: ICodeEditor } | undefined { const item = this._viewItems.get().find(v => - v.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString() - || v.viewModel.diffEditorViewModel.model.original.uri.toString() === resource.toString() + v.viewModel.modifiedUri?.toString() === resource.toString() + || v.viewModel.originalUri?.toString() === resource.toString() ); const editor = item?.template.get()?.editor; - if (!editor) { + if (!editor || item.viewModel.isBinary) { return undefined; } - if (item.viewModel.diffEditorViewModel.model.modified.uri.toString() === resource.toString()) { + if (item.viewModel.modifiedUri?.toString() === resource.toString()) { return { diffEditor: editor, editor: editor.getModifiedEditor() }; } else { return { diffEditor: editor, editor: editor.getOriginalEditor() }; @@ -617,7 +636,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } } if (focusEditor) { - editor?.focus(); + item.binding.get()?.focus(); } } @@ -786,7 +805,7 @@ class VirtualizedViewItem extends Disposable implements ILoggedDiffItem, ICompre } public override toString(): string { - return `VirtualViewItem(${this.viewModel.documentDiffItem.modified?.uri.toString()})`; + return `VirtualViewItem(${this.viewModel.modifiedUri?.toString() ?? this.viewModel.originalUri?.toString()})`; } public getKey(): string { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css index 595eba2d6407b4..138aa773b0b21b 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -154,5 +154,24 @@ .editorContainer { flex: 1; } + + .binary-file-placeholder { + display: none; + flex: 1; + place-items: center; + color: var(--vscode-descriptionForeground); + + &:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + .binary-file-placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--vscode-spacing-size80); + } + } } } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index f853df6d75ad9f..a5f61ea899d1c7 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -46,6 +46,9 @@ export interface IWorkbenchUIElementFactory { /** Handles a middle-click on an entry header. Returns whether the event was handled. */ handleHeaderMiddleClick?(resource: URI): boolean; + /** Opens an entry in a standalone diff editor. */ + openDiffEditor?(original: URI, modified: URI): void; + /** * Optional override for how individual actions render in the per-file header * toolbar (`MenuId.MultiDiffEditorFileToolbar`). Return `undefined` to fall diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index 7150e1bc0e3cef..e988b9cb967f57 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import sinon from 'sinon'; import { Dimension } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { Event, ValueWithChangeEvent } from '../../../../base/common/event.js'; import { autorun, waitForState } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -21,7 +22,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../platform/st import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/diffProviderFactoryService.js'; import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; -import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; +import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -82,6 +83,97 @@ suite('MultiDiffEditorWidget', () => { } }); + test('renders binary files as a placeholder', async () => { + const services = new ServiceCollection(); + services.set(IAccessibilitySignalService, new class extends mock() { }()); + services.set(IActionViewItemService, new NullActionViewItemService()); + services.set(IEditorProgressService, new class extends mock() { }()); + services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); + services.set(IStorageService, disposables.add(new InMemoryStorageService())); + services.set(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override getActions() { return []; } + override dispose(): void { } + }(); + } + }()); + const instantiationService = createCodeEditorServices(disposables, services); + const originalUri = URI.parse('inmemory://original/image.png'); + const modifiedUri = URI.parse('inmemory://modified/image.png'); + const documentItem = RefCounted.createOfNonDisposable({ + original: new DiffItemSource(originalUri, undefined), + modified: new DiffItemSource(modifiedUri, undefined), + }, { dispose() { } }); + const model: IMultiDiffEditorModel = { + documents: ValueWithChangeEvent.const([documentItem]), + }; + let openedDiff: { original: URI; modified: URI } | undefined; + const container = document.createElement('div'); + const widget = instantiationService.createInstance( + MultiDiffEditorWidget, + container, + { + openDiffEditor: (original, modified) => openedDiff = { original, modified }, + } satisfies IWorkbenchUIElementFactory, + undefined, + ); + widget.layout(new Dimension(800, 600)); + const viewModel = widget.createViewModel(model); + await waitForState(viewModel.items, items => items.length === 1); + widget.setViewModel(viewModel); + widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); + await waitForState(widget.getLayoutDebugState(), state => state.items[0]?.hasTemplate === true); + + try { + const placeholder = widget.getRootElement().querySelector('.binary-file-placeholder'); + const editor = widget.getRootElement().querySelector('.editorContainer'); + const openDiffButton = placeholder?.querySelector('.monaco-button'); + const focusSpy = sinon.spy(Button.prototype, 'focus'); + const canFocusActiveItem = widget.focus(); + openDiffButton?.click(); + assert.deepStrictEqual({ + text: placeholder?.textContent, + display: placeholder?.style.display, + tabIndex: placeholder?.tabIndex, + role: placeholder?.getAttribute('role'), + ariaLabel: placeholder?.getAttribute('aria-label'), + openDiffButtonText: openDiffButton?.textContent, + openDiffButtonSecondary: openDiffButton?.classList.contains('secondary'), + openDiffButtonFocused: focusSpy.calledOnce, + openedOriginalUri: openedDiff?.original.toString(), + openedModifiedUri: openedDiff?.modified.toString(), + editorDisplay: editor?.style.display, + itemHeight: widget.getLayoutDebugState().get().items[0].verticalState.contentHeight, + canFocusActiveItem, + findsDocumentItem: widget.findDocumentDiffItem(modifiedUri) === documentItem.object, + hasCodeEditorForBinaryResource: widget.tryGetCodeEditor(modifiedUri) !== undefined, + }, { + text: 'Binary file changedOpen Diff', + display: 'grid', + tabIndex: -1, + role: 'group', + ariaLabel: 'Binary file changed', + openDiffButtonText: 'Open Diff', + openDiffButtonSecondary: true, + openDiffButtonFocused: true, + openedOriginalUri: originalUri.toString(), + openedModifiedUri: modifiedUri.toString(), + editorDisplay: 'none', + itemHeight: 140, + canFocusActiveItem: true, + findsDocumentItem: true, + hasCodeEditorForBinaryResource: false, + }); + } finally { + widget.setViewModel(undefined); + viewModel.dispose(); + widget.dispose(); + documentItem.dispose(); + } + }); + test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); @@ -105,8 +197,8 @@ suite('MultiDiffEditorWidget', () => { const original = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 2;', undefined, undefined, modifiedUri)); const documentItem = RefCounted.createOfNonDisposable({ - original, - modified, + original: new DiffItemSource(originalUri, original), + modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } }); const model: IMultiDiffEditorModel = { @@ -181,7 +273,7 @@ suite('MultiDiffEditorWidget', () => { const originalContent = Array.from({ length: 64 }, (_, index) => `line ${index}`).join('\n'); const original = disposables.add(instantiateTextModel(instantiationService, originalContent, undefined, undefined, originalUri)); const documentItem = RefCounted.createOfNonDisposable({ - original, + original: new DiffItemSource(originalUri, original), modified: undefined, options: { accessibilitySupport: 'off' }, }, { dispose() { } }); @@ -259,8 +351,8 @@ suite('MultiDiffEditorWidget', () => { const original = disposables.add(instantiateTextModel(instantiationService, '', undefined, undefined, originalUri)); const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, modifiedUri)); documentItems.push(RefCounted.createOfNonDisposable({ - original, - modified, + original: new DiffItemSource(originalUri, original), + modified: new DiffItemSource(modifiedUri, modified), options: { accessibilitySupport: 'off' }, }, { dispose() { } })); originalUris.push(originalUri); diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 78bb549841ebe6..ae9a30f2bc5604 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -23,6 +23,7 @@ export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; */ export const AgentHostPullRequestOperationId = { MarkReady: 'pr-mark-ready', + MarkReadyWithAgentMerge: 'pr-mark-ready-with-agent-merge', Merge: 'pr-merge', EnableAutoMerge: 'pr-enable-auto-merge', DisableAutoMerge: 'pr-disable-auto-merge', diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index bea5719dbf86ba..bcb870a235ec85 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -260,48 +260,48 @@ export interface AgentMergeDisableReason { export const agentMergeDisableReasons = { sessionArchived: (): AgentMergeDisableReason => ({ log: 'the session was archived', - notice: localize('agentMerge.disabled.sessionArchived', "Agent Merge was turned off because this session was archived."), + notice: localize('agentMerge.disabled.sessionArchived', "Agent Merge was disabled because this session was archived."), }), branchChanged: (from: string, to: string): AgentMergeDisableReason => ({ log: `branch changed from ${from} to ${to}`, notice: localize( 'agentMerge.disabled.branchChanged', - "Agent Merge was turned off because the checked-out branch changed from {0} to {1}.", + "Agent Merge was disabled because the checked-out branch changed from {0} to {1}.", appendEscapedMarkdownInlineCode(from), appendEscapedMarkdownInlineCode(to) ), }), branchChangedWhileRefreshing: (): AgentMergeDisableReason => ({ log: 'the checked-out branch changed while pull request state was refreshing', - notice: localize('agentMerge.disabled.branchChangedWhileRefreshing', "Agent Merge was turned off because the checked-out branch changed while its pull request state was refreshing."), + notice: localize('agentMerge.disabled.branchChangedWhileRefreshing', "Agent Merge was disabled because the checked-out branch changed while its pull request state was refreshing."), }), differentPullRequest: (): AgentMergeDisableReason => ({ log: 'the session became associated with a different pull request', - notice: localize('agentMerge.disabled.differentPullRequest', "Agent Merge was turned off because this session became associated with a different pull request."), + notice: localize('agentMerge.disabled.differentPullRequest', "Agent Merge was disabled because this session became associated with a different pull request."), }), invalidPullRequestUrl: (): AgentMergeDisableReason => ({ log: 'the associated pull request URL is invalid', - notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was turned off because the associated pull request URL is invalid."), + notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was disabled because the associated pull request URL is invalid."), }), differentGitHubHost: (): AgentMergeDisableReason => ({ log: 'the bound pull request belongs to a different GitHub host than the signed-in account', - notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was turned off because its pull request belongs to a different GitHub host than the signed-in account."), + notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was disabled because its pull request belongs to a different GitHub host than the signed-in account."), }), indeterminate: (minutes: number, reason: string): AgentMergeDisableReason => ({ log: `the pull request state could not be evaluated for ${minutes} minutes: ${reason}`, - notice: localize('agentMerge.disabled.indeterminate', "Agent Merge was turned off because its pull request state could not be evaluated for {0} minutes.", minutes), + notice: localize('agentMerge.disabled.indeterminate', "Agent Merge was disabled because its pull request state could not be evaluated for {0} minutes.", minutes), }), pullRequestClosed: (): AgentMergeDisableReason => ({ log: 'the pull request is closed or merged', - notice: localize('agentMerge.disabled.pullRequestClosed', "Agent Merge was turned off because its pull request is closed or merged."), + notice: localize('agentMerge.disabled.pullRequestClosed', "Agent Merge was disabled because its pull request is closed or merged."), }), repairBudgetExhausted: (): AgentMergeDisableReason => ({ log: 'the same pull request blockers remained after repeated repair attempts', - notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was turned off because the same pull request blockers remained after repeated repair attempts."), + notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was disabled because the same pull request blockers remained after repeated repair attempts."), }), - pullRequestMerged: (): AgentMergeDisableReason => ({ + pullRequestMerged: (pullRequestNumber: number, pullRequestUrl: string): AgentMergeDisableReason => ({ log: 'the pull request was merged', - notice: localize('agentMerge.disabled.pullRequestMerged', "Agent Merge merged its pull request and turned itself off."), + notice: localize('agentMerge.pullRequestMerged', "Agent Merge merged pull request [#{0}]({1}).", pullRequestNumber, pullRequestUrl), }), } as const; @@ -309,8 +309,8 @@ export const agentMergeDisableReasons = { export function agentMergeEnabledNotice(target: Pick, configuration: AgentMergeConfiguration): string { const lines = [ target.pullRequestUrl - ? localize('agentMerge.notice.enabled.withPullRequest', "Agent Merge is on for {0} and is monitoring its pull request.", appendEscapedMarkdownInlineCode(target.branchName)) - : localize('agentMerge.notice.enabled', "Agent Merge is on for {0}. It will wait for a pull request on this branch, then monitor it.", appendEscapedMarkdownInlineCode(target.branchName)), + ? localize('agentMerge.notice.enabled.withPullRequest', "Agent Merge is enabled for {0} and is monitoring its pull request.", appendEscapedMarkdownInlineCode(target.branchName)) + : localize('agentMerge.notice.enabled', "Agent Merge is enabled for {0}. It will wait for a pull request on this branch, then monitor it.", appendEscapedMarkdownInlineCode(target.branchName)), ]; if (configuration.addressReviews) { lines.push(localize('agentMerge.notice.enabled.addressReviews', "It will ask the agent to address new pull request review comments.")); @@ -424,9 +424,9 @@ function agentMergeMergeBehaviorChangedNotice(mergePullRequest: AgentMergeMergeP } } -/** The transcript notice shown when the user, rather than the controller, turns Agent Merge off. */ +/** The transcript notice shown when the user, rather than the controller, disables Agent Merge. */ export function agentMergeDisabledNotice(): string { - return localize('agentMerge.notice.disabled', "Agent Merge was turned off for this session."); + return localize('agentMerge.notice.disabled', "Agent Merge was disabled for this session."); } /** @@ -434,7 +434,7 @@ export function agentMergeDisabledNotice(): string { * because its own repair work changed the pull request. */ export function agentMergeMergePullRequestDemotedNotice(): string { - return localize('agentMerge.notice.mergeDemoted', "Agent Merge changed this pull request, so automatic merging was turned off for this session. Review the changes, then turn it back on if you want it merged automatically."); + return localize('agentMerge.notice.mergeDemoted', "Agent Merge changed this pull request, so automatic merging was disabled for this session. Review the changes, then enable it again if you want it merged automatically."); } export function readAgentMergeSessionState(values: Record | undefined): AgentMergeSessionState | undefined { @@ -628,16 +628,7 @@ export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration: return { kind: 'indeterminate', reason: checks.reason, cause: `checks:${checks.reason}` }; } - const reviewThreads = snapshot.reviewThreads.value! - .filter(thread => !thread.isResolved && thread.comments.some(comment => isAgentMergeFeedbackAuthor(comment.author))); - const latestReviews = latestReviewsByAuthor(snapshot.submittedReviews.value!); - const changesRequested = latestReviews.filter(review => review.state.toUpperCase() === 'CHANGES_REQUESTED' && isAgentMergeFeedbackAuthor(review.author)); - const watermark = Date.parse(commentWatermark); - const newComments = snapshot.topLevelComments.value!.filter(comment => - isAgentMergeFeedbackAuthor(comment.author) - && comment.createdAt !== undefined - && Date.parse(comment.createdAt) > watermark - ); + const { reviewThreads, changesRequested, newComments } = getAgentMergeFeedback(snapshot, commentWatermark); const mergeability = snapshot.mergeability.value!; const behind = mergeability.mergeStateStatus?.toUpperCase() === 'BEHIND'; const conflicting = mergeability.mergeable === 'CONFLICTING'; @@ -762,6 +753,49 @@ function latestReviewsByAuthor(reviews: PullRequestSnapshot['submittedReviews'][ return [...latest.values()]; } +function getAgentMergeFeedback(snapshot: PullRequestSnapshot, commentWatermark: string) { + const reviewThreads = snapshot.reviewThreads.value! + .filter(thread => !thread.isResolved && thread.comments.some(comment => isAgentMergeFeedbackAuthor(comment.author))); + const latestReviews = latestReviewsByAuthor(snapshot.submittedReviews.value!); + const changesRequested = latestReviews.filter(review => review.state.toUpperCase() === 'CHANGES_REQUESTED' && isAgentMergeFeedbackAuthor(review.author)); + const watermark = Date.parse(commentWatermark); + const newComments = snapshot.topLevelComments.value!.filter(comment => + isAgentMergeFeedbackAuthor(comment.author) + && comment.createdAt !== undefined + && Date.parse(comment.createdAt) > watermark + ); + return { reviewThreads, changesRequested, newComments }; +} + +/** + * Returns whether a draft pull request has no pending or failed required checks + * and no actionable review feedback, or `undefined` while that state is incomplete. + */ +export function isAgentMergePullRequestReadyForReview(snapshot: PullRequestSnapshot, commentWatermark: string): boolean | undefined { + const core = snapshot.core; + if (core.status !== 'ready' || !core.complete || !core.value || core.value.state !== 'open' || !core.value.draft) { + return undefined; + } + for (const fragment of conversationFragments) { + if (!isCompleteFragment(snapshot, fragment)) { + return undefined; + } + } + if (!isCompleteHeadFragment(snapshot, 'checks', core.value.headSha)) { + return undefined; + } + const checks = classifyAgentMergeRequiredChecks(snapshot.checks.value!); + if (checks.kind === 'indeterminate') { + return undefined; + } + const { reviewThreads, changesRequested, newComments } = getAgentMergeFeedback(snapshot, commentWatermark); + return checks.failed.length === 0 + && !checks.pending + && reviewThreads.length === 0 + && changesRequested.length === 0 + && newComments.length === 0; +} + export function classifyAgentMergeRequiredChecks(checks: PullRequestChecks): AgentMergeRequiredChecks { if (!checks.requirednessComplete) { return { kind: 'indeterminate', reason: 'Required check classification is incomplete' }; diff --git a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts index a96df3fe4b87fc..daf53e0da977ec 100644 --- a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts @@ -5,12 +5,20 @@ export const enum AgentSystemNotificationKind { WorktreeCreationFailure = 'worktreeCreationFailure', + /** An automatic approval review did not finish before its deadline. */ + AutomaticApprovalReviewTimedOut = 'automaticApprovalReviewTimedOut', + /** An automatic approval review stopped before reaching a decision. */ + AutomaticApprovalReviewAborted = 'automaticApprovalReviewAborted', + /** Automatic approval review denials triggered the turn circuit breaker. */ + AutomaticApprovalReviewInterrupted = 'automaticApprovalReviewInterrupted', /** Agent Merge started monitoring the session's branch. */ AgentMergeEnabled = 'agentMergeEnabled', /** Effective Agent Merge behavior changed while monitoring. */ AgentMergeConfigurationChanged = 'agentMergeConfigurationChanged', /** Agent Merge stopped monitoring the session, usually on its own. */ AgentMergeDisabled = 'agentMergeDisabled', + /** Agent Merge merged the pull request it was monitoring. */ + AgentMergePullRequestMerged = 'agentMergePullRequestMerged', } export const enum AgentSystemNotificationSeverity { @@ -19,9 +27,13 @@ export const enum AgentSystemNotificationSeverity { const knownKinds: ReadonlySet = new Set([ AgentSystemNotificationKind.WorktreeCreationFailure, + AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut, + AgentSystemNotificationKind.AutomaticApprovalReviewAborted, + AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted, AgentSystemNotificationKind.AgentMergeEnabled, AgentSystemNotificationKind.AgentMergeConfigurationChanged, AgentSystemNotificationKind.AgentMergeDisabled, + AgentSystemNotificationKind.AgentMergePullRequestMerged, ]); interface IHasSystemNotificationMeta { diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts index c1602c25d3b51d..189f3377fbe9b6 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -38,6 +38,7 @@ export type PullRequestLifecycleAction = 'mark-ready' | 'merge' | 'enable-auto-m export class AgentHostPullRequestLifecycleOperationHandler implements IChangesetOperationHandler { public static readonly OPERATION_MARK_READY = AgentHostPullRequestOperationId.MarkReady; + public static readonly OPERATION_MARK_READY_WITH_AGENT_MERGE = AgentHostPullRequestOperationId.MarkReadyWithAgentMerge; public static readonly OPERATION_MERGE = AgentHostPullRequestOperationId.Merge; public static readonly OPERATION_ENABLE_AUTO_MERGE = AgentHostPullRequestOperationId.EnableAutoMerge; public static readonly OPERATION_DISABLE_AUTO_MERGE = AgentHostPullRequestOperationId.DisableAutoMerge; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 7685d9fea8c38a..9540d7a79e4e63 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -72,6 +72,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash'; public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase'; public static readonly OPERATION_CREATE_PR_AGENT_MERGE = 'create-pr-agent-merge'; + public static readonly OPERATION_CREATE_DRAFT_PR_AGENT_MERGE = 'create-draft-pr-agent-merge'; constructor( private readonly _draft: boolean, @@ -337,7 +338,9 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation if (this._enableAgentMerge) { return isExisting ? localize('agentHost.changeset.pr.existing.agentMerge', "Pull request [#{0}]({1}) already exists; enabled Agent Merge.", pr.number, pr.url) - : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); + : this._draft + ? localize('agentHost.changeset.pr.createdDraft.agentMerge', "Created draft pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url) + : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); } let mergeMethodLabel: string | undefined; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 8e4ee53a0880ff..20a2a86a7393e9 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -15,8 +15,9 @@ import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } fr import { AgentHostPullRequestLifecycleOperationHandler } from './agentHostPullRequestLifecycleOperationHandler.js'; import { IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; -import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { ActionType } from '../common/state/sessionActions.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { @@ -49,15 +50,18 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', false, getSessionState, resolveBaseBranchName, onCreated); const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', false, getSessionState, resolveBaseBranchName, onCreated); const createAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, true, getSessionState, resolveBaseBranchName, onCreated); + const createDraftAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, true, getSessionState, resolveBaseBranchName, onCreated); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, createAgentMergePrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE, createDraftAgentMergePrHandler)); for (const [operationId, action] of [ [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, 'mark-ready'], + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE, 'mark-ready'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MERGE, 'merge'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_ENABLE_AUTO_MERGE, 'enable-auto-merge'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_DISABLE_AUTO_MERGE, 'disable-auto-merge'], @@ -66,6 +70,11 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem } store.add(this._pullRequestStatusService.onDidChangePullRequestStatus(sessionKey => registry.onDidChangeOperations(sessionKey))); + store.add(this._stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionConfigChanged) { + registry.onDidChangeOperations(envelope.channel); + } + })); let agentMergeEnabled = this._isAgentMergeEnabled(); store.add(this._configurationService.onDidRootConfigChange(() => { const nextAgentMergeEnabled = this._isAgentMergeEnabled(); @@ -112,6 +121,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem return undefined; } + const agentMergeEnabled = this._isAgentMergeEnabled(); return [{ id: 'create-pr', label: localize('agentHost.changeset.createPR', "Create PR"), @@ -144,9 +154,9 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, }, - ...(this._isAgentMergeEnabled() ? [{ + ...(agentMergeEnabled ? [{ id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, - label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Enable Agent Merge"), + label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Agent Merge"), icon: 'git-merge', group: 'pull-request', scopes: [ChangesetOperationScope.Changeset], @@ -159,7 +169,16 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem group: 'pull-request_draft', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, - }] satisfies ChangesetOperation[]; + }, + ...(agentMergeEnabled ? [{ + id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE, + label: localize('agentHost.changeset.createDraftPRAgentMerge', "Create Draft PR & Agent Merge"), + icon: 'git-merge', + group: 'pull-request_draft', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] : []), + ] satisfies ChangesetOperation[]; } private _isAgentMergeEnabled(): boolean { @@ -193,8 +212,12 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const operations: ChangesetOperation[] = []; if (status.draft) { + const agentMergeRunning = this._isAgentMergeRunning(sessionKey); + const operationId = agentMergeRunning && status.agentMergeReadyForReview !== true + ? AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE + : AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY; operations.push({ - id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, + id: operationId, label: localize('agentHost.changeset.markReady', "Mark Ready"), description: localize('agentHost.changeset.markReady.description', "Take the pull request out of draft so it can be reviewed and merged."), icon: 'git-pull-request', @@ -243,6 +266,11 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem return operations; } + private _isAgentMergeRunning(sessionKey: string): boolean { + return this._isAgentMergeEnabled() + && readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values)?.enabled === true; + } + /** * Logs the advertised operations whenever the set changes for a session. * `getOperations` is recomputed on every git/GitHub state change and once diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts index e7a11c311b4c1a..b2f03010679d71 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts @@ -9,7 +9,7 @@ import { Disposable, DisposableStore, type IDisposable } from '../../../base/com import { autorun } from '../../../base/common/observable.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription, PullRequestSubscriptionOptions } from '../../github/common/githubPullRequestService.js'; import type { GitHubCredentialInvalidation } from '../../github/common/githubCredentialService.js'; import type { GitHubAccountHandle } from '../../github/common/githubTypes.js'; import { IGitHubService } from '../../github/common/githubService.js'; @@ -19,6 +19,7 @@ import { getSessionRelatedPullRequestUrls, hasSessionPullRequestForBranch, isSes import { ActionType } from '../common/state/sessionActions.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { parsePullRequestUrl } from './agentMergeController.js'; +import { isAgentMergePullRequestReadyForReview, readAgentMergeSessionState } from '../common/agentMerge.js'; /** * Merge states GitHub reports for a pull request that can still be merged @@ -42,6 +43,8 @@ export interface IAgentHostPullRequestStatus { readonly draft: boolean; /** True once the pull request is open and can be merged as-is. */ readonly mergeReady: boolean; + /** Whether Agent Merge has observed all required checks and review feedback as ready. */ + readonly agentMergeReadyForReview?: boolean; readonly viewerCanEnableAutoMerge: boolean; readonly autoMergeEnabled: boolean; readonly allowedMergeMethods: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]; @@ -55,10 +58,10 @@ export const IAgentHostPullRequestStatusService = createDecorator this._sync(session))); this._register(this._stateManager.onDidRemoveSession(session => this._stopWatch(session))); this._register(this._stateManager.onDidEmitEnvelope(envelope => { - if (envelope.action.type === ActionType.SessionIsArchivedChanged) { + if (envelope.action.type === ActionType.SessionIsArchivedChanged || envelope.action.type === ActionType.SessionConfigChanged) { this._sync(envelope.channel); } })); @@ -259,6 +262,8 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg const existing = this._watches.get(sessionKey); if (existing && sameRefAndHost(existing.ref, parsed)) { + existing.subscription.update(this._getSubscriptionOptions(sessionKey)); + this._updateStatus(sessionKey, existing, existing.subscription.resource.snapshot.get()); return; } @@ -288,11 +293,7 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg this._stopWatch(sessionKey, `replaced by ${describeRef(ref)}`); const store = new DisposableStore(); - const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, { - priority: 'visible', - core: true, - mergeability: true, - })); + const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, this._getSubscriptionOptions(sessionKey))); const watch: IWatch = { ref, subscription, @@ -313,6 +314,24 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg this._logService.debug(`[AgentHostPullRequestStatusService] Watching pull request: session=${sessionKey}, pr=${describeRef(ref)}`); } + private _getSubscriptionOptions(sessionKey: string): PullRequestSubscriptionOptions { + const agentMergeEnabled = readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values)?.enabled === true; + return { + priority: 'visible', + core: true, + mergeability: true, + ...(agentMergeEnabled ? { + conversation: { + topLevelComments: true, + submittedReviews: true, + reviewThreads: true, + includeBodies: true, + }, + checks: { required: true }, + } : {}), + }; + } + private _hasPersistedMergedState(sessionKey: string, ref: PullRequestRef): boolean { const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; @@ -371,7 +390,8 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg if (snapshot.core.status !== 'ready' && (watch.status?.state === 'merged' || persistedMergedStateApplies)) { return; } - this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot)); + const agentMerge = readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values); + this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot, agentMerge?.enabled ? agentMerge.target?.commentWatermark : undefined)); } private _setStatus(sessionKey: string, watch: IWatch, status: IAgentHostPullRequestStatus | undefined): void { @@ -429,6 +449,7 @@ function describeStatus(status: IAgentHostPullRequestStatus | undefined): string `state=${status.state}`, `draft=${status.draft}`, `mergeReady=${status.mergeReady}`, + `agentMergeReadyForReview=${status.agentMergeReadyForReview ?? 'unknown'}`, `autoMergeEnabled=${status.autoMergeEnabled}`, `canEnableAutoMerge=${status.viewerCanEnableAutoMerge}`, `allowedMergeMethods=${status.allowedMergeMethods.join('|') || 'none'}`, @@ -440,7 +461,7 @@ function describeStatus(status: IAgentHostPullRequestStatus | undefined): string * while either fragment the button bar depends on is still unresolved. Holding * back on partial data keeps the client from flashing a wrong primary button. */ -function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullRequestStatus | undefined { +function toPullRequestStatus(snapshot: PullRequestSnapshot, agentMergeCommentWatermark?: string): IAgentHostPullRequestStatus | undefined { const core = snapshot.core.value; if (!core) { return undefined; @@ -467,6 +488,9 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque if (!mergeability || snapshot.mergeability.headSha !== core.headSha) { return undefined; } + const agentMergeReadyForReview = agentMergeCommentWatermark !== undefined + ? isAgentMergePullRequestReadyForReview(snapshot, agentMergeCommentWatermark) + : undefined; return { ...(core.id ? { pullRequestId: core.id } : {}), @@ -479,6 +503,7 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque && mergeability.mergeable === 'MERGEABLE' && mergeability.viewerCanMerge && MERGEABLE_STATES.has(mergeability.mergeStateStatus?.toUpperCase() ?? 'CLEAN'), + ...(agentMergeReadyForReview !== undefined ? { agentMergeReadyForReview } : {}), viewerCanEnableAutoMerge: mergeability.viewerCanEnableAutoMerge, autoMergeEnabled: mergeability.autoMergeEnabled, allowedMergeMethods: mergeability.allowedMergeMethods, diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 3545e22b34316b..8021c5efc0cacb 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -420,7 +420,11 @@ export class AgentMergeController extends Disposable { let target = agentMerge.target; if (!target) { const now = new Date().toISOString(); - target = { branchName, enabledAt: now, commentWatermark: now }; + const currentGitHubState = readSessionGitHubState(this._stateManager.getSessionState(session)?._meta); + const pullRequestUrl = currentGitHubState?.pullRequestBranchName === branchName + ? getSessionRelatedPullRequestUrls(currentGitHubState)[0] + : undefined; + target = { branchName, enabledAt: now, commentWatermark: now, ...(pullRequestUrl ? { pullRequestUrl } : {}) }; this._logService.info(`[AgentMergeController] Captured session branch and feedback watermark: session=${session}`); // Announce only on the first capture: a resumed session already has a // target, so restarting the host must not repeat the notice. @@ -811,7 +815,13 @@ export class AgentMergeController extends Disposable { } const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, runtime.abortController.signal); this._logService.info(`[AgentMergeController] Pull request merged natively: session=${session}, method=${method}, outcome=${result.outcome}`); - this._disable(session, currentState, agentMergeDisableReasons.pullRequestMerged()); + const mergedPullRequest = preparation.snapshot.core.value!; + this._disable( + session, + currentState, + agentMergeDisableReasons.pullRequestMerged(mergedPullRequest.number, mergedPullRequest.url), + AgentSystemNotificationKind.AgentMergePullRequestMerged, + ); } private async _completeTurn(session: string): Promise { @@ -946,14 +956,14 @@ export class AgentMergeController extends Disposable { }); } - private _disable(session: string, current: AgentMergeSessionState, reason: AgentMergeDisableReason): void { + private _disable(session: string, current: AgentMergeSessionState, reason: AgentMergeDisableReason, notificationKind = AgentSystemNotificationKind.AgentMergeDisabled): void { this._logService.info(`[AgentMergeController] Disabling Agent Merge for ${session}: ${reason.log}`); this._activeTurns.delete(session); // Claim the transition before the config write re-enters `_doSyncSession`, // so the reasoned notice below is the only one the user sees. this._monitoredSessions.delete(session); this._announcedConfigurations.delete(session); - this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, reason.notice); + this._postNotice(session, notificationKind, reason.notice); const patch: Record = { [SessionConfigKey.AgentMerge]: { enabled: false, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 4cbb70f842b2ac..28ea2ccc7de2a9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3259,6 +3259,7 @@ export class AgentService extends Disposable implements IAgentService { if (!hasCompletedTurn && !activeTurn) { throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`); } + const sourceHasProviderFork = this._stateManager.getChatOrigin(sourceChatKey)?.kind !== ChatOriginKind.Tool; let anchorTurnId: string | undefined; if (activeTurn) { anchorTurnId = resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(sourceChatKey, turnId)); @@ -3278,7 +3279,7 @@ export class AgentService extends Disposable implements IAgentService { ...(selection ? { selection } : {}), }, sourceChat: sourceChatKey, - shouldFork: !activeTurn || anchorTurnId !== undefined, + shouldFork: sourceHasProviderFork && (!activeTurn || anchorTurnId !== undefined), ...(selection ? { selection } : {}), ...(anchorTurnId ? { anchorTurnId } : {}), }; diff --git a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts index 78e8e271743626..5f7ec7fefe16ca 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts @@ -39,12 +39,11 @@ export class SideChatContribution extends Disposable implements IAgentHostChatCo const sourceState = this._stateManager.getChatState(origin.chat); const activeTurn = sourceState?.activeTurn?.id === origin.turnId ? sourceState.activeTurn : undefined; - const forkAnchorTurnId = activeTurn + const sourceIsToolChat = this._stateManager.getChatOrigin(origin.chat)?.kind === ChatOriginKind.Tool; + const forkAnchorTurnId = activeTurn && !sourceIsToolChat ? resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(origin.chat, turnId)) : undefined; - // A completed SDK-backed turn is already carried by the provider's fork. - // Only active and host-injected local turns are missing from that history. - const sourceContext = activeTurn || this._localTurns.isLocal(origin.chat, origin.turnId) + const sourceContext = sourceIsToolChat || activeTurn || this._localTurns.isLocal(origin.chat, origin.turnId) ? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], origin.turnId, activeTurn, forkAnchorTurnId) : undefined; const partialResponse = getSideChatPartialResponse(activeTurn); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 7a7a77019c5b6f..2143c0f4240a38 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { CCAModel } from '@vscode/copilot-api'; import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -24,6 +25,7 @@ import { IProductService } from '../../../product/common/productService.js'; import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelGroupMeta, createAgentModelSourceMeta } from '../../common/agentModelSource.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; @@ -145,7 +147,7 @@ import type { GuardianWarningNotification } from './protocol/generated/v2/Guardi import type { ThreadApproveGuardianDeniedActionResponse } from './protocol/generated/v2/ThreadApproveGuardianDeniedActionResponse.js'; import type { ConfigReadResponse } from './protocol/generated/v2/ConfigReadResponse.js'; import type { ConfigWriteResponse } from './protocol/generated/v2/ConfigWriteResponse.js'; -import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; +import { formatGuardianDenialNotification, formatGuardianReviewStatusNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; import { CODEX_COMPACT_SLASH_COMMAND } from '../codexCompactCommand.js'; const CLIENT_INFO = { @@ -164,6 +166,7 @@ const CODEX_THREAD_TURNS_PAGE_SIZE = 100; const CODEX_STARTUP_ACCOUNT_PROBE_TIMEOUT_MS = 30_000; const CODEX_DESKTOP_WORKSPACE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const CODEX_DESKTOP_SESSION_META_PATTERN = /"type"\s*:\s*"session_meta".*"payload"\s*:\s*\{[^}]*"originator"\s*:\s*"Codex Desktop"/s; +const CODEX_GUARDIAN_TURN_INTERRUPTION_PREFIX = 'Automatic approval review rejected too many approval requests for this turn'; function isCodexDesktopGeneratedWorkspace(cwd: string, userHome: URI): boolean { const relativePath = extUriBiasedIgnorePathCase.relativePath(userHome, URI.file(cwd)); @@ -240,6 +243,17 @@ const CODEX_COPILOT_MODEL_GROUP = 'copilot'; const CODEX_OPENAI_MODEL_PROVIDER = 'openai'; const CODEX_MODEL_SELECTION_PREFIX = '@provider='; +/** + * The Codex harness relies on OpenAI Responses semantics beyond the endpoint + * shape. Other vendors can advertise `/responses` without supporting the full + * Codex request lifecycle, so only publish OpenAI's picker-eligible models. + */ +function isCodexCompatibleCopilotModel(model: CCAModel): boolean { + return model.vendor.toLowerCase() === CODEX_OPENAI_MODEL_PROVIDER + && !!model.model_picker_enabled + && !!model.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT); +} + export function toCodexModelSelectionId(modelProvider: string, modelId: string): string { return `${CODEX_MODEL_SELECTION_PREFIX}${encodeURIComponent(modelProvider)}:${encodeURIComponent(modelId)}`; } @@ -591,9 +605,9 @@ interface ICodexSession { */ readonly acceptedForSession: Set; /** - * Guardian (auto-review) `reviewId`s that have already been surfaced to - * the user as a denied-action approval card. Guards against acting twice - * on the same review if the completed notification is redelivered. + * Guardian (auto-review) `reviewId`s whose terminal outcome has already + * been surfaced. Guards against acting twice on the same review if the + * completed notification is redelivered. */ readonly handledGuardianReviews: Set; /** @@ -1984,7 +1998,7 @@ export class CodexAgent extends Disposable implements IAgent { // OpenAI-shaped Responses endpoint. The chosen id is forwarded straight // through; CAPI remains the authority on what the token may actually use. const models = all - .filter(m => m.model_picker_enabled && m.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT)) + .filter(isCodexCompatibleCopilotModel) .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) .map((m): IAgentModelInfo => ({ provider: CODEX_AGENT_PROVIDER_ID, @@ -2461,11 +2475,12 @@ export class CodexAgent extends Disposable implements IAgent { subscriptions.add(client.onNotification('thread/tokenUsage/updated', params => this._dispatchTokenUsageUpdated(params))); subscriptions.add(client.onNotification('item/completed', params => this._dispatchItemCompleted(params))); subscriptions.add(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params))); - // Auto-review (guardian) surfacing. The guardian warning is shown as a - // system notification; a completed *denied* review is turned into a + // Auto-review (guardian) surfacing. The guardian turn-interruption warning + // is shown as a system notification; terminal review failures are surfaced + // from the structured completion event, and a denied review also gets a // retroactive "Approve anyway" tool-call card. The review lifecycle is - // non-blocking (codex does not wait on us), so the completed handler is - // async and resolves its session directly rather than via _dispatchByThread. + // non-blocking (codex does not wait on us), so the completed handler is async + // and resolves its session directly rather than via _dispatchByThread. subscriptions.add(client.onNotification('guardianWarning', params => this._dispatchByThread(params.threadId, s => this._handleGuardianWarning(s, params)))); subscriptions.add(client.onNotification('item/autoApprovalReview/completed', params => { void this._handleGuardianReviewCompleted(client, params); })); @@ -3664,6 +3679,11 @@ export class CodexAgent extends Disposable implements IAgent { } private _handleGuardianWarning(session: ICodexSession, params: GuardianWarningNotification): ChatAction[] { + // Individual review outcomes are handled by the structured review event. The + // warning channel is only needed when the review circuit breaker ends a turn. + if (!params.message.startsWith(CODEX_GUARDIAN_TURN_INTERRUPTION_PREFIX)) { + return []; + } const turnId = session.currentTurnId; if (turnId === undefined) { this._logService.trace(`[Codex:${session.sessionId}] guardianWarning without active turn; ignoring`); @@ -3675,6 +3695,7 @@ export class CodexAgent extends Disposable implements IAgent { part: { kind: ResponsePartKind.SystemNotification, content: params.message, + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted }), }, }]; } @@ -3686,18 +3707,18 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.trace(`[Codex] autoApprovalReview/completed for unknown threadId=${params.threadId}; ignoring`); return; } - if (params.review.status !== 'denied') { + const status = params.review.status; + if (status === 'approved' || status === 'inProgress') { return; } if (session.handledGuardianReviews.has(params.reviewId)) { return; } - // Bind the denial surfacing to the review's OWN turn (mapped app→host), + // Bind review surfacing to the review's OWN turn (mapped app→host), // not whatever turn happens to be current. An `autoApprovalReview/completed` // that arrives out of order — after its turn ended, or once a later turn is - // active — must not mis-attribute the notice/card to a different turn, nor - // apply this review's stale action against it. When the review's turn is no - // longer the active turn there is nothing left to approve within it, so ignore. + // active — must not mis-attribute its notice to a different turn. A denied + // review's override also stops being actionable after its turn ends. const turnId = this._hostTurnId(session, params.turnId); if (session.currentTurnId !== turnId) { this._logService.trace(`[Codex:${sessionId}] autoApprovalReview/completed for non-current turn ${turnId} (current=${session.currentTurnId ?? '(none)'}); ignoring reviewId=${params.reviewId}`); @@ -3707,6 +3728,21 @@ export class CodexAgent extends Disposable implements IAgent { session.handledGuardianReviews.add(params.reviewId); const summary = summarizeGuardianReviewAction(params.action); + if (status === 'timedOut' || status === 'aborted') { + const kind = status === 'timedOut' + ? AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut + : AgentSystemNotificationKind.AutomaticApprovalReviewAborted; + this._fire(session.sessionUri, { + type: ActionType.ChatResponsePart, + turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content: formatGuardianReviewStatusNotification(summary, status, params.review.rationale), + _meta: toAgentSystemNotificationMeta({ kind }), + }, + }); + return; + } // Durable record: a Markdown response part survives turn completion AND is // rendered by the live streaming path (unlike a system-notification part, diff --git a/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts b/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts index d73e4ee94ed418..f5a736cbacf195 100644 --- a/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts +++ b/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts @@ -7,6 +7,7 @@ import type { GuardianApprovalReviewAction } from './protocol/generated/v2/Guard import type { ItemGuardianApprovalReviewCompletedNotification } from './protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; import type { RequestPermissionProfile } from './protocol/generated/v2/RequestPermissionProfile.js'; import type { JsonValue } from './protocol/generated/serde_json/JsonValue.js'; +import { localize } from '../../../../nls.js'; import { unwrapShellInvocation } from './codexShellCommand.js'; /** @@ -199,3 +200,21 @@ export function formatGuardianDenialNotification(summary: IGuardianActionSummary // part; trailing newline keeps subsequent model output on its own block. return `\n\n${quoted}\n`; } + +/** Compose a compact, collapsible notification for a review that did not decide. */ +export function formatGuardianReviewStatusNotification(summary: IGuardianActionSummary, status: 'timedOut' | 'aborted', rationale: string | null): string { + const title = status === 'timedOut' + ? localize('codex.guardianReview.timedOut', "Auto-review timed out") + : localize('codex.guardianReview.aborted', "Auto-review stopped"); + const detail = summary.detail?.trim(); + const action = detail ? `${summary.title} ${inlineCode(detail)}` : summary.title; + const lines = [ + title, + localize('codex.guardianReview.requestedAction', "Requested action: {0}", action), + ]; + const reason = rationale?.trim(); + if (reason) { + lines.push('', reason); + } + return lines.join('\n'); +} diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index e3a13d572d71b2..2cddea6e377c72 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -407,6 +407,28 @@ function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMet return parsed; } +async function getCreateSessionCatalog(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise { + const args = (rawArgs ?? {}) as ICreateSessionArgs; + if (getCreateSessionRelationship(args) !== 'independent') { + return []; + } + const workspace = getOptionalString(args.workspace, 'workspace', SessionServerToolName.CreateSession); + if (workspace === undefined) { + return []; + } + try { + // Prefer the catalog because a project display name can also be a valid URI. + return await accessor.listSessions(); + } catch (error) { + // An explicit URI/path is self-contained, so it remains usable when a + // provider cannot enumerate its session catalog. + if (parseWorkspaceUri(workspace) !== undefined) { + return []; + } + throw error; + } +} + function resolveModel(modelName: string | undefined, models: readonly IAgentModelInfo[], provider?: AgentProvider): IAgentModelInfo | undefined { if (modelName === undefined) { return undefined; @@ -720,7 +742,7 @@ export interface ICreateSessionResult { * Creates work with the requested relationship and sends its initial prompt. */ export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string): Promise { - const sessions = await accessor.listSessions(); + const sessions = await getCreateSessionCatalog(accessor, rawArgs); const currentSession = source ? currentSessionUri(source.toString()) : undefined; const currentProvider = currentSession ? AgentSession.provider(currentSession) : undefined; const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider); @@ -1391,7 +1413,11 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs))); } case SessionServerToolName.GetCurrentSession: - return serializeCurrentSession(currentSessionUri(currentChannel), await accessor.listSessions()); + { + const currentSession = currentSessionUri(currentChannel); + const metadata = await accessor.getSession(currentSession); + return serializeCurrentSession(currentSession, metadata ? [metadata] : []); + } case SessionServerToolName.CreateSession: { const relationship = getCreateSessionRelationship(rawArgs); if (relationship === 'currentSession' && createdChatCount >= maxCreatedChats) { diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index dece3d12ed626c..50dbc8854e713e 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentMergeConfiguration, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeEnabledNotice, evaluateAgentMerge, getNonMergeSessionConfigValues, readAgentMergeSessionState, shouldStopMergingAfterAgentChanges } from '../../common/agentMerge.js'; +import { AgentMergeConfiguration, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeDisableReasons, agentMergeEnabledNotice, evaluateAgentMerge, getNonMergeSessionConfigValues, isAgentMergePullRequestReadyForReview, readAgentMergeSessionState, shouldStopMergingAfterAgentChanges } from '../../common/agentMerge.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; @@ -75,6 +75,62 @@ suite('Agent Merge gate', () => { }); }); + test('repairs draft pull requests without merging them', () => { + const repair = evaluateAgentMerge(readySnapshot({ + draft: true, + reviewThreads: [{ + id: 'thread-1', + isResolved: false, + comments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this' }], + }], + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'COMPLETED', conclusion: 'FAILURE' }], + }), configuration, '2026-08-02T00:00:00.000Z'); + const ready = evaluateAgentMerge(readySnapshot({ draft: true }), configuration, '2026-08-02T00:00:00.000Z'); + + assert.deepStrictEqual({ + repair: repair.kind === 'prompt' ? repair.actions : repair.kind, + ready: ready.kind, + }, { + repair: ['addressReviews', 'fixCI'], + ready: 'noWork', + }); + }); + + test('reports when required checks and review feedback are ready', () => { + const watermark = '2026-08-02T00:00:00.000Z'; + assert.deepStrictEqual({ + ready: isAgentMergePullRequestReadyForReview(readySnapshot({ draft: true }), watermark), + notDraft: isAgentMergePullRequestReadyForReview(readySnapshot(), watermark), + pendingChecks: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'IN_PROGRESS' }], + }), watermark), + failingChecks: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'COMPLETED', conclusion: 'FAILURE' }], + }), watermark), + reviewComments: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + reviewThreads: [{ + id: 'thread-1', + isResolved: false, + comments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this' }], + }], + }), watermark), + newComments: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + topLevelComments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this', createdAt: '2026-08-03T00:00:00.000Z' }], + }), watermark), + }, { + ready: true, + notDraft: undefined, + pendingChecks: false, + failingChecks: false, + reviewComments: false, + newComments: false, + }); + }); + test('merges only from complete ready state', () => { assert.deepStrictEqual(evaluateAgentMerge(readySnapshot(), configuration, '2026-08-02T00:00:00.000Z').kind, 'merge'); }); @@ -215,7 +271,7 @@ suite('Agent Merge gate', () => { ...configuration, mergePullRequest: 'never', }), [ - 'Agent Merge is on for `feature`. It will wait for a pull request on this branch, then monitor it.', + 'Agent Merge is enabled for `feature`. It will wait for a pull request on this branch, then monitor it.', 'It will ask the agent to address new pull request review comments.', 'It will ask the agent to fix failing CI checks.', 'It will ask the agent to resolve merge conflicts and update the branch when it falls behind.', @@ -225,6 +281,13 @@ suite('Agent Merge gate', () => { ].map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n')); }); + test('reports when Agent Merge merges a pull request', () => { + assert.strictEqual( + agentMergeDisableReasons.pullRequestMerged(123, 'https://github.com/octo/repo/pull/123').notice, + 'Agent Merge merged pull request [#123](https://github.com/octo/repo/pull/123).', + ); + }); + test('describes effective Agent Merge configuration changes', () => { const previous: AgentMergeConfiguration = { ...configuration, @@ -262,7 +325,7 @@ suite('Agent Merge gate', () => { mergePullRequest: 'always', mergeMethod: 'squash', }), [ - 'Agent Merge is on for `feature` and is monitoring its pull request.', + 'Agent Merge is enabled for `feature` and is monitoring its pull request.', 'It will ask the agent to fix failing CI checks.', 'It will ask the agent to resolve merge conflicts and update the branch when it falls behind.', 'After each update, it will wait for new CI results.', @@ -372,6 +435,7 @@ suite('Agent Merge gate', () => { }); function readySnapshot(overrides?: { + readonly draft?: boolean; readonly topLevelComments?: NonNullable; readonly submittedReviews?: NonNullable; readonly reviewThreads?: NonNullable; @@ -390,7 +454,7 @@ function readySnapshot(overrides?: { title: 'Change', url: 'https://github.com/octo/repo/pull/1', state: 'open', - draft: false, + draft: overrides?.draft ?? false, headSha: 'head', headRef: 'feature', baseSha: 'base', diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 0441df9a391c1f..6f800c856eefd7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -362,6 +362,35 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('creates a draft pull request and enables Agent Merge', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + draft: true, + enableAgentMerge: true, + }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + octoCalls: octoKitService.calls, + sessionConfigUpdates, + }, { + message: { markdown: 'Created draft pull request [#123](https://github.com/microsoft/vscode/pull/123) and enabled Agent Merge.' }, + octoCalls: [ + 'findPullRequestByHeadBranch:feature/test', + 'createPullRequest:true', + ], + sessionConfigUpdates: [{ + [SessionConfigKey.AgentMerge]: { + enabled: true, + }, + [SessionConfigKey.AgentMergeController]: {}, + }], + }); + }); + test('creates a generated branch before committing when the current branch is the base branch', async () => { const gitService = new TestGitService(); gitService.uncommitted = true; diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index d25bea09e54de8..83d5f6ad19b4b2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -10,6 +10,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; +import { AgentHostPullRequestLifecycleOperationHandler } from '../../node/agentHostPullRequestLifecycleOperationHandler.js'; import type { IAgentHostPullRequestStatus, IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; import { SessionStatus, type ISessionGitHubState, type ISessionGitState } from '../../common/state/sessionState.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; @@ -73,10 +74,10 @@ const pullRequestForBranch: ISessionGitHubState = { suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false, sessionAgentMergeEnabled = false): AgentHostPullRequestOperationContribution { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - if (isolation) { - stateManager.createSession({ + if (isolation || sessionAgentMergeEnabled) { + const session = { resource: 'agent:/session', provider: 'copilot', title: 'Session', @@ -84,10 +85,18 @@ suite('AgentHostPullRequestOperationContribution', () => { createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), workingDirectories: ['file:///repo'], - }); + }; + if (sessionAgentMergeEnabled) { + stateManager.restoreSession(session, []); + } else { + stateManager.createSession(session); + } stateManager.setSessionConfig('agent:/session', { schema: { type: 'object', properties: {} }, - values: { [SessionConfigKey.Isolation]: isolation }, + values: { + ...(isolation ? { [SessionConfigKey.Isolation]: isolation } : {}), + ...(sessionAgentMergeEnabled ? { [SessionConfigKey.AgentMerge]: { enabled: true } } : {}), + }, }); } const configurationService = new class extends mock() { @@ -113,12 +122,20 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); - test('advertises Create PR and Enable Agent Merge as the last Create PR option when Agent Merge is enabled', () => { + test('advertises Agent Merge variants as the last ready and draft Create PR options when Agent Merge is enabled', () => { const provider = createContribution(undefined, undefined, Event.None, true); const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); - assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-pr-agent-merge', 'create-draft-pr']); + assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [ + { id: 'create-pr', label: 'Create PR' }, + { id: 'create-pr-auto-merge', label: 'Create PR (Auto-Merge)' }, + { id: 'create-pr-auto-squash', label: 'Create PR (Auto-Squash)' }, + { id: 'create-pr-auto-rebase', label: 'Create PR (Auto-Rebase)' }, + { id: 'create-pr-agent-merge', label: 'Create PR & Agent Merge' }, + { id: 'create-draft-pr', label: 'Create Draft PR' }, + { id: 'create-draft-pr-agent-merge', label: 'Create Draft PR & Agent Merge' }, + ]); }); test('does not advertise PR operations for folder sessions with outgoing changes', () => { @@ -158,7 +175,7 @@ suite('AgentHostPullRequestOperationContribution', () => { }); test('advertises lifecycle operations for a pull request on the current branch', () => { - const operationsFor = (status?: IAgentHostPullRequestStatus) => createContribution(status) + const operationsFor = (status?: IAgentHostPullRequestStatus, agentMergeEnabled = false) => createContribution(status, undefined, Event.None, agentMergeEnabled, agentMergeEnabled) .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }) ?.map(op => op.id); @@ -166,6 +183,9 @@ suite('AgentHostPullRequestOperationContribution', () => { unresolved: operationsFor(undefined), merged: operationsFor(openPullRequest({ state: 'merged' })), draft: operationsFor(openPullRequest({ draft: true, viewerCanEnableAutoMerge: true })), + agentMergeDraftWaiting: operationsFor(openPullRequest({ draft: true, agentMergeReadyForReview: false }), true), + agentMergeDraftUnknown: operationsFor(openPullRequest({ draft: true }), true), + agentMergeDraftReady: operationsFor(openPullRequest({ draft: true, agentMergeReadyForReview: true }), true), mergeable: operationsFor(openPullRequest({ mergeReady: true })), blocked: operationsFor(openPullRequest({ viewerCanEnableAutoMerge: true })), autoMerging: operationsFor(openPullRequest({ autoMergeEnabled: true })), @@ -174,6 +194,9 @@ suite('AgentHostPullRequestOperationContribution', () => { unresolved: undefined, merged: undefined, draft: ['pr-mark-ready', 'pr-enable-auto-merge'], + agentMergeDraftWaiting: ['pr-mark-ready-with-agent-merge'], + agentMergeDraftUnknown: ['pr-mark-ready-with-agent-merge'], + agentMergeDraftReady: ['pr-mark-ready'], mergeable: ['pr-merge'], blocked: ['pr-enable-auto-merge'], autoMerging: ['pr-disable-auto-merge'], @@ -181,4 +204,14 @@ suite('AgentHostPullRequestOperationContribution', () => { }); }); + test('uses the same label for the Agent Merge Mark Ready operation', () => { + const operations = createContribution(openPullRequest({ draft: true, agentMergeReadyForReview: false }), undefined, Event.None, true, true) + .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [{ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE, + label: 'Mark Ready', + }]); + }); + }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts index 6d794cc64d4b81..587019287468e8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts @@ -10,13 +10,15 @@ import { observableValue } from '../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } from '../../../github/common/githubCredentialService.js'; -import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../../github/common/githubPullRequestService.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription, PullRequestSubscriptionOptions } from '../../../github/common/githubPullRequestService.js'; import type { IGitHubService } from '../../../github/common/githubService.js'; import type { IPullRequestResources } from '../../../github/common/pullRequestResourceService.js'; import { mock } from '../../../../base/test/common/mock.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { readSessionGitHubState, SessionStatus, withSessionGitHubState, withSessionGitState, type ISessionGitHubState, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; @@ -77,10 +79,33 @@ function snapshot(ref: PullRequestRef, overrides?: { readonly draft?: boolean; r } as PullRequestSnapshot; } +function agentMergeReadySnapshot(ref: PullRequestRef): PullRequestSnapshot { + const result = snapshot(ref, { draft: true }); + const ready = { status: 'ready', complete: true } as const; + return { + ...result, + topLevelComments: { ...ready, value: [] }, + submittedReviews: { ...ready, value: [] }, + reviewThreads: { ...ready, headSha: 'sha1', value: [] }, + checks: { + ...ready, + headSha: 'sha1', + value: { + headSha: 'sha1', + requirednessComplete: true, + expectedSuites: [], + expectedSuitesComplete: true, + checks: [], + }, + }, + }; +} + /** Records subscription lifecycle so tests can assert nothing is leaked. */ class TestPullRequestResources implements IPullRequestResources { readonly subscribed: PullRequestRef[] = []; + readonly subscriptionOptions: PullRequestSubscriptionOptions[] = []; disposedCount = 0; private _snapshot = observableValue('snapshot', undefined); private _nextSubscriptionSnapshot: PullRequestSnapshot | undefined; @@ -88,13 +113,14 @@ class TestPullRequestResources implements IPullRequestResources { get liveSubscriptions(): number { return this.subscribed.length - this.disposedCount; } - subscribePullRequest(ref: PullRequestRef): PullRequestSubscription { + subscribePullRequest(ref: PullRequestRef, options: PullRequestSubscriptionOptions): PullRequestSubscription { this.subscribed.push(ref); + this.subscriptionOptions.push(options); this._snapshot.set(this._nextSubscriptionSnapshot ?? snapshot(ref), undefined); this._nextSubscriptionSnapshot = undefined; return { resource: { ref, snapshot: this._snapshot as never }, - update: () => { }, + update: next => this.subscriptionOptions.push(next), refresh: async () => this.refreshHandler?.(), dispose: () => { this.disposedCount++; }, } as PullRequestSubscription; @@ -251,6 +277,70 @@ suite('AgentHostPullRequestStatusService', () => { }); }); + test('tracks review readiness in the host only while Agent Merge is enabled', async () => { + const { service, stateManager, subscriptions, resources, session } = createHarness(); + stateManager.setSessionConfig(session, { + schema: { type: 'object', properties: {} }, + values: { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature', + pullRequestUrl, + enabledAt: new Date(1).toISOString(), + commentWatermark: '', + }, + }, + }, + }); + resources.setNextSubscriptionSnapshot(agentMergeReadySnapshot({ ...account, owner: 'octo', repo: 'repo', number: 7 })); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + const enabled = { + options: resources.subscriptionOptions.at(-1), + readyForReview: service.getPullRequestStatus(session)?.agentMergeReadyForReview, + }; + + stateManager.dispatchServerAction(session, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AgentMerge]: { enabled: false } }, + replace: true, + }); + await pump(); + + assert.deepStrictEqual({ + enabled, + disabled: { + options: resources.subscriptionOptions.at(-1), + readyForReview: service.getPullRequestStatus(session)?.agentMergeReadyForReview, + }, + }, { + enabled: { + options: { + priority: 'visible', + core: true, + mergeability: true, + conversation: { + topLevelComments: true, + submittedReviews: true, + reviewThreads: true, + includeBodies: true, + }, + checks: { required: true }, + }, + readyForReview: true, + }, + disabled: { + options: { + priority: 'visible', + core: true, + mergeability: true, + }, + readyForReview: undefined, + }, + }); + }); + test('optimistically records a successful merge in host pull request state', async () => { const { service, subscriptions, resources, gitHubStates, session } = createHarness(); subscriptions.addSubscription(session, `${session}/changes`); diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 215e77e6d4dbf1..02c2bffdef5d75 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -17,7 +17,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSystemNotificationKind } from '../../common/meta/agentSystemNotificationMeta.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -435,6 +435,7 @@ suite('AgentMergeController', () => { const gitStateService = new class extends mock() { override readonly onDidRefreshSessionGitState = Event.None; override readonly onDidChangeSessionGitHubState = Event.None; + override async attachSessionGitHubPullRequest(): Promise { } }(); const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); const notices: { kind: AgentSystemNotificationKind; content: string }[] = []; @@ -500,7 +501,13 @@ suite('AgentMergeController', () => { schema: platformSessionSchema.toProtocol(), values: {}, }); - stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' })); + stateManager.setSessionMeta(session, withSessionGitHubState( + withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' }), + { + pullRequestUrls: ['https://github.com/octo/repo/pull/1'], + pullRequestBranchName: 'other', + }, + )); const captured = new Promise(resolve => { disposables.add(stateManager.onDidChangeSessionConfig(event => { if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { @@ -533,12 +540,57 @@ suite('AgentMergeController', () => { kind: AgentSystemNotificationKind.AgentMergeEnabled, content: agentMergeEnabledNotice({ branchName: 'feature' }, defaultAgentMergeConfiguration), }, - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because the checked-out branch changed from `feature` to `main`.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because the checked-out branch changed from `feature` to `main`.' }, ], enabled: false, }); }); + test('announces a known pull request when it captures the Agent Merge target', async () => { + const { stateManager, configurationService, session, notices } = createControllerHarness(disposables); + const pullRequestUrl = 'https://github.com/octo/repo/pull/1'; + stateManager.setSessionMeta(session, withSessionGitHubState( + withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' }), + { + pullRequestUrls: [pullRequestUrl], + pullRequestBranchName: 'feature', + }, + )); + const captured = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { + resolve(); + } + })); + }); + + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: true } }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await captured; + const target = readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.target; + + assert.deepStrictEqual({ + target: target ? { + branchName: target.branchName, + pullRequestUrl: target.pullRequestUrl, + hasEnabledAt: target.enabledAt.length > 0, + watermarkMatchesEnablement: target.commentWatermark === target.enabledAt, + } : undefined, + notices, + }, { + target: { + branchName: 'feature', + pullRequestUrl, + hasEnabledAt: true, + watermarkMatchesEnablement: true, + }, + notices: [{ + kind: AgentSystemNotificationKind.AgentMergeEnabled, + content: agentMergeEnabledNotice({ branchName: 'feature', pullRequestUrl }, defaultAgentMergeConfiguration), + }], + }); + }); + test('announces effective session and global configuration changes while monitoring', () => { const { stateManager, configurationService, session, notices } = createControllerHarness(disposables); const target = { @@ -622,10 +674,10 @@ suite('AgentMergeController', () => { configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: false } }); assert.deepStrictEqual({ afterSelfDisable, notices }, { - afterSelfDisable: [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }], + afterSelfDisable: [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because this session was archived.' }], notices: [ - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }, - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off for this session.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because this session was archived.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled for this session.' }, ], }); }); @@ -657,7 +709,7 @@ suite('AgentMergeController', () => { assert.deepStrictEqual(notices, [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', }, { kind: AgentSystemNotificationKind.AgentMergeEnabled, content: agentMergeEnabledNotice({ branchName: 'feature' }, defaultAgentMergeConfiguration), diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5c920384c1963c..433c7638bb1579 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10235,6 +10235,28 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('creates a fresh side chat from a completed tool-origin chat', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const sourceChat = buildSubagentChatUri(session, 'tool-1'); + getStateManager(service).addChat(session.toString(), sourceChat, { + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(session), toolCallId: 'tool-1' }, + turns: [completedTurn('t1')], + }); + const chatUri = URI.parse(buildChatUri(session, 'side-1')); + + await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 't1' } }); + + assert.deepStrictEqual({ + origin: getStateManager(service).getChatState(chatUri.toString())?.origin, + forkForwarded: agent.lastCreateOptions?.fork, + }, { + origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 't1' }, + forkForwarded: undefined, + }); + }); + test('creates a fresh peer with a SideChat origin and no copied source turns', async () => { const agent = disposables.add(new SideChatAgent('copilot')); registerTestAgentProvider(service, agent); @@ -15128,7 +15150,7 @@ suite('AgentService (node dispatcher)', () => { state: TurnState.Complete, responseParts: [{ kind: ResponsePartKind.SystemNotification, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', _meta: { kind: 'agentMergeDisabled' }, }], sentToAgent: 0, @@ -15211,7 +15233,7 @@ suite('AgentService (node dispatcher)', () => { afterTurn: { responseParts: [{ kind: ResponsePartKind.SystemNotification, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', _meta: { kind: 'agentMergeDisabled' }, }], anchoredTo: ['agent-turn'], diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 931bb10c53427e..13c33567e8a619 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -699,11 +699,11 @@ function createContributions(disposables: ReturnType, inheritedTurnId?: string, selectionText?: string) { +function createSideChatContributions(disposables: ReturnType, inheritedTurnId?: string, selectionText?: string, options?: { readonly sourceIsToolChat?: boolean }) { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const session = 'agent-host-session://side-chat'; - const sourceChat = buildDefaultChatUri(session); + const sourceChat = options?.sourceIsToolChat ? buildSubagentChatUri(session, 'source-tool') : buildDefaultChatUri(session); const sideChat = buildChatUri(session, 'side'); stateManager.createSession({ resource: session, @@ -713,6 +713,12 @@ function createSideChatContributions(disposables: ReturnType { }); }); + test('includes source transcript for a completed tool-origin side-chat source turn', async () => { + const sideChat = createSideChatContributions(disposables, undefined, 'selected text', { sourceIsToolChat: true }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'source question', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'source-turn', + duration: 1, + }); + + const first = await sideChat.service.outgoingTurn({ + session: sideChat.session, + chat: sideChat.sideChat, + message: { text: 'side question', origin: { kind: MessageKind.User } }, + turnId: 'side-turn', + }); + + assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\nsource question', 'selected text')); + }); + + test('keeps completed turns before an active tool-origin side-chat source turn', async () => { + const sideChat = createSideChatContributions(disposables, undefined, undefined, { sourceIsToolChat: true }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'completed-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'completed question', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'completed-turn', + duration: 1, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: '2025-01-01T00:00:01.000Z', + message: { text: 'active question', origin: { kind: MessageKind.User } }, + }); + + const first = await sideChat.service.outgoingTurn({ + session: sideChat.session, + chat: sideChat.sideChat, + message: { text: 'side question', origin: { kind: MessageKind.User } }, + turnId: 'side-turn', + }); + + assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\ncompleted question\n\n---\n\nUser request:\nactive question')); + }); + test('includes source transcript for an active side-chat source turn', async () => { const sideChat = createSideChatContributions(disposables); sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index be0f0153c0b4f1..6d4e7745a8a8bf 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -11,13 +11,17 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentChatMigrationDeferred, AgentSession, CODEX_AGENT_PROVIDER_ID, type AgentProvider, type IAgentChatContext, type IAgentDiscoveredChat } from '../../../common/agent.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; +import { ActionType, type ChatAction } from '../../../common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpServerStatus, type McpServerCustomization } from '../../../common/state/protocol/channels-session/state.js'; -import { buildDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js'; +import { buildDefaultChatUri, parseRequiredSessionUriFromChatUri, ResponsePartKind } from '../../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { getCustomizationEnablementKey, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../../node/agentHostCustomizationEnablementService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { CodexClientCustomizationStore, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; import type { ICodexMcpServerConfigJson, ICodexMcpServerEntry } from '../../../node/codex/codexMcpServers.js'; +import type { ItemGuardianApprovalReviewCompletedNotification } from '../../../node/codex/protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; +import type { GuardianWarningNotification } from '../../../node/codex/protocol/generated/v2/GuardianWarningNotification.js'; import { targetForMcpServer } from '../../../node/shared/customizationEnablementGate.js'; import { McpCustomizationController, type IMcpCustomizationControllerOptions } from '../../../node/shared/mcpCustomizationController.js'; import { createGitHubMcpServerConfiguration, getGitHubMcpTools } from '../../../node/shared/githubMcpServer.js'; @@ -79,6 +83,31 @@ interface ICodexAuthenticateHarness { authenticate(resource: string, token: string): Promise; } +interface ICodexGuardianWarningHarness { + readonly _logService: NullLogService; +} + +interface ICodexGuardianWarningSession { + readonly sessionId: string; + readonly currentTurnId: string | undefined; +} + +interface ICodexGuardianReviewSession { + readonly sessionId: string; + readonly sessionUri: URI; + readonly currentTurnId: string | undefined; + readonly hostTurnIdByAppTurnId: Map; + readonly handledGuardianReviews: Set; +} + +interface ICodexGuardianReviewHarness { + readonly _logService: NullLogService; + readonly _sessionIdByThreadId: Map; + readonly _sessions: Map; + _hostTurnId(session: ICodexGuardianReviewSession, appTurnId: string): string; + _fire(sessionUri: URI, action: ChatAction): void; +} + function resolveConversationSession(harness: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined { const resolver = (CodexAgent.prototype as unknown as { _resolveConversationSession(this: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined; @@ -100,6 +129,20 @@ function handleMcpRequest(harness: ICodexMcpRequestHarness, chat: URI): Promise< return handler.call(harness, chat, 'server', 'tools/list', undefined); } +function handleGuardianWarning(harness: ICodexGuardianWarningHarness, session: ICodexGuardianWarningSession, params: GuardianWarningNotification): ChatAction[] { + const handler = (CodexAgent.prototype as unknown as { + _handleGuardianWarning(this: ICodexGuardianWarningHarness, session: ICodexGuardianWarningSession, params: GuardianWarningNotification): ChatAction[]; + })._handleGuardianWarning; + return handler.call(harness, session, params); +} + +function handleGuardianReviewCompleted(harness: ICodexGuardianReviewHarness, params: ItemGuardianApprovalReviewCompletedNotification): Promise { + const handler = (CodexAgent.prototype as unknown as { + _handleGuardianReviewCompleted(this: ICodexGuardianReviewHarness, client: never, params: ItemGuardianApprovalReviewCompletedNotification): Promise; + })._handleGuardianReviewCompleted; + return handler.call(harness, undefined as never, params); +} + function emptyHarness(): ICodexConversationResolverHarness { return { id: CODEX_AGENT_PROVIDER_ID, _sessionIdByChatUri: new Map() }; } @@ -108,6 +151,104 @@ suite('CodexAgent', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('ignores guardian review outcome warnings handled by structured events', () => { + const harness: ICodexGuardianWarningHarness = { _logService: new NullLogService() }; + const session: ICodexGuardianWarningSession = { sessionId: 'session', currentTurnId: 'turn' }; + + for (const message of [ + 'Automatic approval review approved (risk: low, authorization: high): Safe read.', + 'Automatic approval review denied (risk: high, authorization: unknown): Unsafe action.', + 'Automatic approval review timed out while evaluating the requested approval.', + ]) { + assert.deepStrictEqual(handleGuardianWarning(harness, session, { threadId: 'thread', message }), []); + } + }); + + test('surfaces guardian turn-interruption warnings', () => { + const harness: ICodexGuardianWarningHarness = { _logService: new NullLogService() }; + const session: ICodexGuardianWarningSession = { sessionId: 'session', currentTurnId: 'turn' }; + const message = 'Automatic approval review rejected too many approval requests for this turn (5 consecutive, 5 in the last 10 reviews); interrupting the turn.'; + + assert.deepStrictEqual(handleGuardianWarning(harness, session, { threadId: 'thread', message }), [{ + type: ActionType.ChatResponsePart, + turnId: 'turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: message, + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted }), + }, + }]); + }); + + test('surfaces terminal guardian review failures once on their current turn', async () => { + const actions: ChatAction[] = []; + const session: ICodexGuardianReviewSession = { + sessionId: 'session', + sessionUri: URI.parse('codex:/session'), + currentTurnId: 'host-turn', + hostTurnIdByAppTurnId: new Map([ + ['app-turn', 'host-turn'], + ['stale-app-turn', 'stale-host-turn'], + ]), + handledGuardianReviews: new Set(), + }; + const harness: ICodexGuardianReviewHarness = { + _logService: new NullLogService(), + _sessionIdByThreadId: new Map([['thread', session.sessionId]]), + _sessions: new Map([[session.sessionId, session]]), + _hostTurnId: (reviewSession, appTurnId) => reviewSession.hostTurnIdByAppTurnId.get(appTurnId) ?? appTurnId, + _fire: (_sessionUri, action) => actions.push(action), + }; + const notification = (reviewId: string, status: ItemGuardianApprovalReviewCompletedNotification['review']['status'], turnId = 'app-turn', rationale: string | null = null): ItemGuardianApprovalReviewCompletedNotification => ({ + threadId: 'thread', + turnId, + startedAtMs: 10, + completedAtMs: 20, + reviewId, + targetItemId: null, + decisionSource: 'agent', + review: { status, riskLevel: null, userAuthorization: null, rationale }, + action: { + type: 'networkAccess', + target: 'https://example.com', + host: 'example.com', + protocol: 'https', + port: 443, + }, + }); + + await handleGuardianReviewCompleted(harness, notification('approved', 'approved')); + await handleGuardianReviewCompleted(harness, notification('in-progress', 'inProgress')); + await handleGuardianReviewCompleted(harness, notification('stale', 'timedOut', 'stale-app-turn')); + await handleGuardianReviewCompleted(harness, notification('timed-out', 'timedOut', 'app-turn', 'The reviewer did not respond in time.')); + await handleGuardianReviewCompleted(harness, notification('timed-out', 'timedOut', 'app-turn', 'The reviewer did not respond in time.')); + await handleGuardianReviewCompleted(harness, notification('aborted', 'aborted')); + + assert.deepStrictEqual({ actions, handledReviewIds: [...session.handledGuardianReviews] }, { + actions: [ + { + type: ActionType.ChatResponsePart, + turnId: 'host-turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: 'Auto-review timed out\nRequested action: Network access `https://example.com`\n\nThe reviewer did not respond in time.', + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut }), + }, + }, + { + type: ActionType.ChatResponsePart, + turnId: 'host-turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: 'Auto-review stopped\nRequested action: Network access `https://example.com`', + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewAborted }), + }, + }, + ], + handledReviewIds: ['timed-out', 'aborted'], + }); + }); + test('GitHub MCP injection respects unowned server enablement', () => { const createHarness = (enabled: boolean, customizationEnabled: boolean, token: string | undefined): ICodexGitHubMcpHarness => Object.assign(Object.create(CodexAgent.prototype), { _configurationService: { getRootValue: () => undefined }, diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index e9756d5ceb5bb2..dc9f5abc7d256a 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -176,7 +176,7 @@ function createSessionDatabaseReference(database: ISessionDatabase) { } async function createAgent(disposables: Pick, options: ICreateAgentOptions = {}): Promise { - const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const instantiationService = new TestInstantiationService(); const logService = new NullLogService(); const fileService = disposables.add(new FileService(logService)); diff --git a/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts b/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts index b57533129dcaa2..206d0a56fe8c64 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from '../../../node/codex/codexGuardianReview.js'; +import { formatGuardianDenialNotification, formatGuardianReviewStatusNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from '../../../node/codex/codexGuardianReview.js'; import type { ItemGuardianApprovalReviewCompletedNotification } from '../../../node/codex/protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; suite('codexGuardianReview', () => { @@ -145,4 +145,17 @@ suite('codexGuardianReview', () => { ] ); }); + + test('formatGuardianReviewStatusNotification separates the compact title from review details', () => { + assert.deepStrictEqual( + [ + formatGuardianReviewStatusNotification({ title: 'Network access', detail: 'https://example.com' }, 'timedOut', 'The reviewer did not respond in time.'), + formatGuardianReviewStatusNotification({ title: 'Elevated permissions', detail: '' }, 'aborted', null), + ], + [ + 'Auto-review timed out\nRequested action: Network access `https://example.com`\n\nThe reviewer did not respond in time.', + 'Auto-review stopped\nRequested action: Elevated permissions', + ] + ); + }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 8555b28c3f71d8..cf746e617444d1 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -208,7 +208,7 @@ suite('CodexAgent model refresh', () => { }); test('restored model waits for an authentication refresh queued behind activation', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const firstRefreshStarted = new DeferredPromise(); const releaseFirstRefresh = new DeferredPromise(); const authenticatedRefreshStarted = new DeferredPromise(); @@ -260,7 +260,7 @@ suite('CodexAgent model refresh', () => { }); test('model resolution starts discovery when the catalog is empty', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => copilotModels); agent['_githubToken'] = 'token'; agent['_isSdkResolvableWithoutDownload'] = async () => false; @@ -299,7 +299,7 @@ suite('CodexAgent model refresh', () => { }); test('queues a fresh model refresh when Codex activates during an ambient refresh', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const ambientRefreshStarted = new DeferredPromise(); const ambientCodexRefreshFinished = new DeferredPromise(); const releaseAmbientRefresh = new DeferredPromise(); @@ -838,8 +838,8 @@ suite('CodexAgent model refresh', () => { test('does not publish Copilot models disabled for the model picker', async () => { const models = [ - { id: 'picker-enabled', name: 'Picker Enabled', model_picker_enabled: true, supported_endpoints: ['/responses'] }, - { id: 'picker-disabled', name: 'Picker Disabled', model_picker_enabled: false, supported_endpoints: ['/responses'] }, + { id: 'picker-enabled', name: 'Picker Enabled', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }, + { id: 'picker-disabled', name: 'Picker Disabled', model_picker_enabled: false, supported_endpoints: ['/responses'], vendor: 'OpenAI' }, ] as CCAModel[]; const agent = createAgent(disposables, async () => models); agent['_isSdkResolvableWithoutDownload'] = async () => false; @@ -852,6 +852,24 @@ suite('CodexAgent model refresh', () => { ]); }); + test('publishes only OpenAI Copilot models', async () => { + const models = [ + { id: 'grok-4.5', name: 'Grok 4.5', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'grok-4.5' }, vendor: 'xAI' }, + { id: 'grok-4.6', name: 'Grok 4.6', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'grok-4.6' }, vendor: 'xAI' }, + { id: 'mai-code-1.1-flash', name: 'MAI-Code-1.1-Flash', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'oswe-vscode-modelD' }, vendor: 'Microsoft' }, + { id: 'gpt-5.6', name: 'GPT-5.6', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'gpt-5.6' }, vendor: 'OpenAI' }, + ] as CCAModel[]; + const agent = createAgent(disposables, async () => models); + agent['_isSdkResolvableWithoutDownload'] = async () => false; + + await agent.authenticate(agent.getProtectedResources()[0].resource, 'token'); + await agent.refreshModels(); + + assert.deepStrictEqual(agent.models.get().map(model => model.id), [ + toCodexModelSelectionId('vscode-proxy', 'gpt-5.6'), + ]); + }); + test('waits for an app-server already starting when signed-out use becomes enabled', async () => { const agent = createAgent(disposables, async () => [], {}); const connection = createChatGPTConnection(); @@ -873,7 +891,7 @@ suite('CodexAgent model refresh', () => { }); test('publishes no ChatGPT models when the app server reports no account', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => copilotModels, { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); agent['_githubToken'] = 'token'; agent['_connection'] = createChatGPTConnection(null) as never; @@ -911,7 +929,7 @@ suite('CodexAgent model refresh', () => { test('keeps the last known-good models when a periodic refresh fails', async () => { let shouldFail = false; - const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => { if (shouldFail) { throw new Error('transient failure'); @@ -931,7 +949,7 @@ suite('CodexAgent model refresh', () => { test('retries Copilot model discovery after a transient authentication refresh failure', async () => { let attempts = 0; - const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => { attempts++; if (attempts === 1) { @@ -1007,7 +1025,7 @@ suite('CodexAgent model refresh', () => { }); test('omits the thinking level when a Copilot model advertises no reasoning efforts', async () => { - const model = { id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] } as CCAModel; + const model = { id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' } as CCAModel; const agent = createAgent(disposables, async () => [model]); await agent.authenticate(agent.getProtectedResources()[0].resource, 'token'); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index 92947217be4463..bed7b96fff06f1 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -197,7 +197,7 @@ class TestCodexConfigurationService extends AgentConfigurationService { } async function createAgent(disposables: Pick, options: ICreateAgentOptions = {}): Promise { - const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const instantiationService = new TestInstantiationService(); const logService = new TestCodexLogService(); const fileService = disposables.add(new TestCodexFileService(logService)); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index e64b541415b7d0..14789cd3287629 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -747,6 +747,39 @@ Use the affected provider command with `--grep ""` and tempora Temporarily clear `shellToolReplayUnstableOnLinux`. +### Codex successful shell result text + +- Tests: + - `worktree session uses the resolved worktree as working directory` + - `reads an existing text file` + - `reads a file from a nested directory` + - `lists workspace entries` + - `reads a value from JSON` + - `counts lines in a file` + - `handles a missing file without a session error` + - `runs a deterministic shell command` + - `inspects git status` +- Scope: Codex. +- Expected: successful shell tool completions include the command output in their result text. +- Observed: the turn response contains the expected value, but the successful tool completion can have an empty `text` field. +- Gate: these nine tests remain enabled for other providers and are skipped for Codex. +- Tracking issue: [#329512](https://github.com/microsoft/vscode/issues/329512). +- Failing runs: + - [PR #329485](https://github.com/microsoft/vscode/actions/runs/31132506547/job/92724492870?pr=329485) + - [PR #329492](https://github.com/microsoft/vscode/actions/runs/31130785836/job/92718953820?pr=329492) + - [PR #329517](https://github.com/microsoft/vscode/actions/runs/31148098482/job/92771783938?pr=329517) + - [PR #329867](https://github.com/microsoft/vscode/actions/runs/31342377741/job/93319069992?pr=329867) + - [Build 469897](https://dev.azure.com/monacotools/a6d41577-0fa3-498e-af22-257312ff0545/_build/results?buildId=469897&view=logs&j=e352877c-ff47-5dec-32e2-b206099d9704&t=b83513b4-f303-5ddc-d18a-1b47c02d8dad) +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "handles a missing file without a session error" + ``` + + Temporarily clear `shellToolResultTextUnreliable`. + ### Claude subagent replay on Windows - Test: `reopening a session keeps sub-agent messages out of the parent transcript (replay path)`. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..7e545880af416c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..710a5f7715188a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..7e545880af416c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..c68f9fba9dc4c3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..a5f8e32009413a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..68eda740449a2c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,209 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: mcp__host__list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: mcp__host__remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: mcp__host__add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: mcp__host__list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: mcp__host__remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..b019114c44a4ca --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,40 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: claude-opus-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: thinking + - type: tool_use + name: mcp__host__rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..93eb80ff51fb0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..25d126cafb6c23 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..93eb80ff51fb0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..696583956cfc7c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: text + text: Got it — I’ll record that reference now. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: Got it — I’ll record that reference now. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..d4ffe47aa466c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: text + text: I’ll record that artifact now, then return the requested response. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: I’ll record that artifact now, then return the requested response. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Server tool add_artifact_or_reference failed: Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..b02c9b371f3222 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,221 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: text + text: I’ll add that reference now, then confirm. + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: I’ll add that reference now, then confirm. + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..ba17e83c76ad79 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: tool_use + name: rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml new file mode 100644 index 00000000000000..8f0771a3980287 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-a-github-remote-with-changes-advertises-pull-request-creation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml new file mode 100644 index 00000000000000..7dfb7f7b74a81e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-archiving-a-never-restored-session-survives-a-host-restart.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml new file mode 100644 index 00000000000000..8f0771a3980287 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-enabling-agent-merge-adds-and-removes-its-pull-request-operation.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ready". + response: + content: ready + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml new file mode 100644 index 00000000000000..b8b08ae422ac36 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-records-a-reference-in-session-state.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Agent Host guide + isArtifact: false + link: https://example.com/agent-host + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Agent Host guide — https://example.com/agent-host' + response: + content: recorded + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml new file mode 100644 index 00000000000000..b220e05a9986dd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-add-artifact-or-reference-rejects-a-session-management-link.yaml @@ -0,0 +1,44 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected". + - role: assistant + content: + - type: thinking + - type: tool_use + name: add_artifact_or_reference + input: + type: resource + label: Spawned session + isArtifact: true + uri: agent-host-session://copilot/spawned + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.' + response: + content: rejected + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml new file mode 100644 index 00000000000000..4debeeeb7cf3c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-list-and-remove-round-trip-a-recorded-reference.yaml @@ -0,0 +1,209 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + response: + content: + - type: tool_use + id: toolcall_0 + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: added + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + response: + content: + - type: tool_use + id: toolcall_1 + name: list_artifacts_and_references + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + response: + content: listed + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + response: + content: + - type: tool_use + id: toolcall_2 + name: remove_artifact_or_reference + input: + id: ${uuid_0} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added". + - role: assistant + content: + - type: tool_use + name: add_artifact_or_reference + input: + type: website + label: Design notes + isArtifact: false + link: https://example.com/design + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Added reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + - role: assistant + content: added + - role: user + content: Call list_artifacts_and_references exactly once, then reply with exactly "listed". + - role: assistant + content: + - type: tool_use + name: list_artifacts_and_references + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: ${uuid_0} (website, reference) Design notes — https://example.com/design + - role: assistant + content: listed + - role: user + content: Call remove_artifact_or_reference exactly once with id "${uuid_0}", then reply with exactly "removed". + - role: assistant + content: + - type: tool_use + name: remove_artifact_or_reference + input: + id: ${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: 'Removed reference: ${uuid_0} (website, reference) Design notes — https://example.com/design' + response: + content: removed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml new file mode 100644 index 00000000000000..0680aef842d5e9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-rename-chat-renames-the-chat-it-runs-in.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + response: + content: + - type: tool_use + id: toolcall_0 + name: rename_chat + input: + title: Coverage audit + automatic: false + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed". + - role: assistant + content: + - type: tool_use + name: rename_chat + input: + title: Coverage audit + automatic: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Renamed chat to "Coverage audit". + response: + content: renamed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json index 534c5451f6c605..4e8654d0091024 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json @@ -5,35 +5,28 @@ "note": "A symbol is \"covered\" when an E2E test sends or receives it; this does not measure how deeply its semantics are asserted." }, "commands": { - "covered": 29, + "covered": 31, "total": 32, - "percentage": 90.62, + "percentage": 96.87, "uncovered": [ - "fetchAutomationRuns", - "listAutomationTriggerDefinitions", "runAutomation" ] }, "notifications": { - "covered": 4, + "covered": 5, "total": 8, - "percentage": 50, + "percentage": 62.5, "uncovered": [ - "auth/required", "otlp/exportMetrics", "otlp/exportTraces", "root/progress" ] }, "actions": { - "covered": 76, - "total": 95, - "percentage": 80, + "covered": 80, + "total": 96, + "percentage": 83.33, "uncovered": [ - "automation/createRequested", - "automation/removed", - "automation/set", - "automation/updateRequested", "automationRun/cancelRequested", "automationRun/lifecycleChanged", "automationRun/primarySessionChanged", @@ -45,6 +38,7 @@ "chat/reasoning", "chat/toolCallAuthRequired", "chat/toolCallAuthResolved", + "chat/turnResume", "session/activityChanged", "session/defaultChatChanged", "session/workingDirectoryReplaced", diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index e04e4b8fdd316e..9fff945aa2561b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,32 +15,32 @@ }, "total": { "statements": { - "covered": 93666, - "total": 124793, - "percentage": 75.05 + "covered": 101868, + "total": 135034, + "percentage": 75.43 }, "branches": { - "covered": 10985, - "total": 16386, - "percentage": 67.03 + "covered": 12677, + "total": 18587, + "percentage": 68.2 }, "functions": { - "covered": 3534, - "total": 5026, - "percentage": 70.31 + "covered": 3908, + "total": 5528, + "percentage": 70.69 }, "lines": { - "covered": 93666, - "total": 124793, - "percentage": 75.05 + "covered": 101868, + "total": 135034, + "percentage": 75.43 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1227, - "total": 1246, - "percentage": 98.47 + "covered": 1268, + "total": 1287, + "percentage": 98.52 }, "branches": { "covered": 27, @@ -53,9 +53,9 @@ "percentage": 66.66 }, "lines": { - "covered": 1227, - "total": 1246, - "percentage": 98.47 + "covered": 1268, + "total": 1287, + "percentage": 98.52 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -126,8 +126,8 @@ }, "src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts": { "statements": { - "covered": 132, - "total": 132, + "covered": 161, + "total": 161, "percentage": 100 }, "branches": { @@ -141,15 +141,15 @@ "percentage": 100 }, "lines": { - "covered": 132, - "total": 132, + "covered": 161, + "total": 161, "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostChangesetService.ts": { "statements": { - "covered": 306, - "total": 306, + "covered": 295, + "total": 295, "percentage": 100 }, "branches": { @@ -163,15 +163,15 @@ "percentage": 100 }, "lines": { - "covered": 306, - "total": 306, + "covered": 295, + "total": 295, "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostChangesetSubscriptionService.ts": { "statements": { - "covered": 38, - "total": 38, + "covered": 47, + "total": 47, "percentage": 100 }, "branches": { @@ -185,9 +185,31 @@ "percentage": 100 }, "lines": { - "covered": 38, - "total": 38, + "covered": 47, + "total": 47, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/agentHostChatContributionsService.ts": { + "statements": { + "covered": 322, + "total": 324, + "percentage": 99.38 + }, + "branches": { + "covered": 1, + "total": 1, "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 322, + "total": 324, + "percentage": 99.38 } }, "src/vs/platform/agentHost/common/agentHostCheckpointService.ts": { @@ -280,9 +302,9 @@ }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { "statements": { - "covered": 82, - "total": 98, - "percentage": 83.67 + "covered": 85, + "total": 109, + "percentage": 77.98 }, "branches": { "covered": 4, @@ -291,13 +313,13 @@ }, "functions": { "covered": 2, - "total": 3, - "percentage": 66.66 + "total": 4, + "percentage": 50 }, "lines": { - "covered": 82, - "total": 98, - "percentage": 83.67 + "covered": 85, + "total": 109, + "percentage": 77.98 } }, "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { @@ -324,31 +346,31 @@ }, "src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts": { "statements": { - "covered": 30, - "total": 30, - "percentage": 100 + "covered": 88, + "total": 94, + "percentage": 93.61 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 3, + "percentage": 33.33 }, "lines": { - "covered": 30, - "total": 30, - "percentage": 100 + "covered": 88, + "total": 94, + "percentage": 93.61 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { "statements": { - "covered": 403, - "total": 649, - "percentage": 62.09 + "covered": 421, + "total": 667, + "percentage": 63.11 }, "branches": { "covered": 39, @@ -356,14 +378,14 @@ "percentage": 68.42 }, "functions": { - "covered": 16, - "total": 24, - "percentage": 66.66 + "covered": 17, + "total": 25, + "percentage": 68 }, "lines": { - "covered": 403, - "total": 649, - "percentage": 62.09 + "covered": 421, + "total": 667, + "percentage": 63.11 } }, "src/vs/platform/agentHost/common/agentHostFileSystemService.ts": { @@ -390,9 +412,9 @@ }, "src/vs/platform/agentHost/common/agentHostGitService.ts": { "statements": { - "covered": 464, - "total": 477, - "percentage": 97.27 + "covered": 466, + "total": 479, + "percentage": 97.28 }, "branches": { "covered": 25, @@ -405,15 +427,15 @@ "percentage": 85.71 }, "lines": { - "covered": 464, - "total": 477, - "percentage": 97.27 + "covered": 466, + "total": 479, + "percentage": 97.28 } }, "src/vs/platform/agentHost/common/agentHostGitStateService.ts": { "statements": { - "covered": 63, - "total": 63, + "covered": 60, + "total": 60, "percentage": 100 }, "branches": { @@ -427,8 +449,8 @@ "percentage": 100 }, "lines": { - "covered": 63, - "total": 63, + "covered": 60, + "total": 60, "percentage": 100 } }, @@ -456,9 +478,9 @@ }, "src/vs/platform/agentHost/common/agentHostManagedSettings.ts": { "statements": { - "covered": 186, - "total": 306, - "percentage": 60.78 + "covered": 209, + "total": 341, + "percentage": 61.29 }, "branches": { "covered": 4, @@ -467,20 +489,20 @@ }, "functions": { "covered": 2, - "total": 10, - "percentage": 20 + "total": 11, + "percentage": 18.18 }, "lines": { - "covered": 186, - "total": 306, - "percentage": 60.78 + "covered": 209, + "total": 341, + "percentage": 61.29 } }, "src/vs/platform/agentHost/common/agentHostResourceService.ts": { "statements": { - "covered": 156, - "total": 161, - "percentage": 96.89 + "covered": 161, + "total": 166, + "percentage": 96.98 }, "branches": { "covered": 2, @@ -493,9 +515,9 @@ "percentage": 0 }, "lines": { - "covered": 156, - "total": 161, - "percentage": 96.89 + "covered": 161, + "total": 166, + "percentage": 96.98 } }, "src/vs/platform/agentHost/common/agentHostReviewService.ts": { @@ -522,14 +544,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 790, - "total": 881, - "percentage": 89.67 + "covered": 802, + "total": 891, + "percentage": 90.01 }, "branches": { - "covered": 51, - "total": 65, - "percentage": 78.46 + "covered": 53, + "total": 66, + "percentage": 80.3 }, "functions": { "covered": 15, @@ -537,9 +559,9 @@ "percentage": 68.18 }, "lines": { - "covered": 790, - "total": 881, - "percentage": 89.67 + "covered": 802, + "total": 891, + "percentage": 90.01 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -564,16 +586,38 @@ "percentage": 100 } }, + "src/vs/platform/agentHost/common/agentHostSubscriptionService.ts": { + "statements": { + "covered": 39, + "total": 39, + "percentage": 100 + }, + "branches": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 39, + "total": 39, + "percentage": 100 + } + }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 110, - "total": 141, - "percentage": 78.01 + "covered": 119, + "total": 150, + "percentage": 79.33 }, "branches": { "covered": 10, - "total": 31, - "percentage": 32.25 + "total": 32, + "percentage": 31.25 }, "functions": { "covered": 7, @@ -581,9 +625,9 @@ "percentage": 77.77 }, "lines": { - "covered": 110, - "total": 141, - "percentage": 78.01 + "covered": 119, + "total": 150, + "percentage": 79.33 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -610,24 +654,24 @@ }, "src/vs/platform/agentHost/common/agentHostUri.ts": { "statements": { - "covered": 142, - "total": 222, - "percentage": 63.96 + "covered": 186, + "total": 275, + "percentage": 67.63 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 2, + "percentage": 50 }, "functions": { - "covered": 0, - "total": 8, - "percentage": 0 + "covered": 1, + "total": 12, + "percentage": 8.33 }, "lines": { - "covered": 142, - "total": 222, - "percentage": 63.96 + "covered": 186, + "total": 275, + "percentage": 67.63 } }, "src/vs/platform/agentHost/common/agentHostWorkingDirectories.ts": { @@ -654,36 +698,58 @@ }, "src/vs/platform/agentHost/common/agentMerge.ts": { "statements": { - "covered": 255, - "total": 472, - "percentage": 54.02 + "covered": 375, + "total": 678, + "percentage": 55.3 }, "branches": { + "covered": 4, + "total": 35, + "percentage": 11.42 + }, + "functions": { "covered": 3, - "total": 27, - "percentage": 11.11 + "total": 33, + "percentage": 9.09 + }, + "lines": { + "covered": 375, + "total": 678, + "percentage": 55.3 + } + }, + "src/vs/platform/agentHost/common/agentMergePrompt.ts": { + "statements": { + "covered": 113, + "total": 338, + "percentage": 33.43 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 2, - "total": 16, - "percentage": 12.5 + "covered": 0, + "total": 18, + "percentage": 0 }, "lines": { - "covered": 255, - "total": 472, - "percentage": 54.02 + "covered": 113, + "total": 338, + "percentage": 33.43 } }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { "statements": { - "covered": 38, + "covered": 39, "total": 41, - "percentage": 92.68 + "percentage": 95.12 }, "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 + "covered": 4, + "total": 5, + "percentage": 80 }, "functions": { "covered": 1, @@ -691,16 +757,38 @@ "percentage": 50 }, "lines": { - "covered": 38, + "covered": 39, "total": 41, - "percentage": 92.68 + "percentage": 95.12 + } + }, + "src/vs/platform/agentHost/common/agentModelNotices.ts": { + "statements": { + "covered": 38, + "total": 64, + "percentage": 59.37 + }, + "branches": { + "covered": 1, + "total": 11, + "percentage": 9.09 + }, + "functions": { + "covered": 1, + "total": 3, + "percentage": 33.33 + }, + "lines": { + "covered": 38, + "total": 64, + "percentage": 59.37 } }, "src/vs/platform/agentHost/common/agentModelPricing.ts": { "statements": { - "covered": 178, - "total": 281, - "percentage": 63.34 + "covered": 181, + "total": 288, + "percentage": 62.84 }, "branches": { "covered": 4, @@ -713,9 +801,9 @@ "percentage": 37.5 }, "lines": { - "covered": 178, - "total": 281, - "percentage": 63.34 + "covered": 181, + "total": 288, + "percentage": 62.84 } }, "src/vs/platform/agentHost/common/agentModelSource.ts": { @@ -786,9 +874,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1070, - "total": 1237, - "percentage": 86.49 + "covered": 1094, + "total": 1262, + "percentage": 86.68 }, "branches": { "covered": 9, @@ -801,9 +889,9 @@ "percentage": 11.11 }, "lines": { - "covered": 1070, - "total": 1237, - "percentage": 86.49 + "covered": 1094, + "total": 1262, + "percentage": 86.68 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { @@ -835,9 +923,9 @@ "percentage": 85.82 }, "branches": { - "covered": 36, - "total": 45, - "percentage": 80 + "covered": 34, + "total": 43, + "percentage": 79.06 }, "functions": { "covered": 12, @@ -872,58 +960,102 @@ "percentage": 95.83 } }, - "src/vs/platform/agentHost/common/changesetUri.ts": { + "src/vs/platform/agentHost/common/autoModeTiers.ts": { "statements": { - "covered": 320, - "total": 370, - "percentage": 86.48 + "covered": 29, + "total": 45, + "percentage": 64.44 }, "branches": { - "covered": 49, - "total": 61, - "percentage": 80.32 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 19, - "total": 25, - "percentage": 76 + "covered": 0, + "total": 3, + "percentage": 0 }, "lines": { - "covered": 320, - "total": 370, - "percentage": 86.48 + "covered": 29, + "total": 45, + "percentage": 64.44 } }, - "src/vs/platform/agentHost/common/claudeModelConfig.ts": { + "src/vs/platform/agentHost/common/automationMigration.ts": { "statements": { - "covered": 123, - "total": 126, - "percentage": 97.61 + "covered": 25, + "total": 29, + "percentage": 86.2 }, "branches": { - "covered": 5, - "total": 15, + "covered": 1, + "total": 3, "percentage": 33.33 }, "functions": { - "covered": 3, - "total": 4, - "percentage": 75 + "covered": 1, + "total": 1, + "percentage": 100 }, "lines": { - "covered": 123, - "total": 126, - "percentage": 97.61 + "covered": 25, + "total": 29, + "percentage": 86.2 } }, - "src/vs/platform/agentHost/common/claudeProviders.ts": { + "src/vs/platform/agentHost/common/changesetUri.ts": { "statements": { - "covered": 35, - "total": 35, - "percentage": 100 + "covered": 333, + "total": 388, + "percentage": 85.82 }, "branches": { - "covered": 0, + "covered": 50, + "total": 62, + "percentage": 80.64 + }, + "functions": { + "covered": 19, + "total": 26, + "percentage": 73.07 + }, + "lines": { + "covered": 333, + "total": 388, + "percentage": 85.82 + } + }, + "src/vs/platform/agentHost/common/claudeModelConfig.ts": { + "statements": { + "covered": 123, + "total": 126, + "percentage": 97.61 + }, + "branches": { + "covered": 5, + "total": 15, + "percentage": 33.33 + }, + "functions": { + "covered": 3, + "total": 4, + "percentage": 75 + }, + "lines": { + "covered": 123, + "total": 126, + "percentage": 97.61 + } + }, + "src/vs/platform/agentHost/common/claudeProviders.ts": { + "statements": { + "covered": 35, + "total": 35, + "percentage": 100 + }, + "branches": { + "covered": 0, "total": 0, "percentage": 100 }, @@ -962,9 +1094,9 @@ }, "src/vs/platform/agentHost/common/codexAccount.ts": { "statements": { - "covered": 28, - "total": 61, - "percentage": 45.9 + "covered": 49, + "total": 122, + "percentage": 40.16 }, "branches": { "covered": 0, @@ -973,13 +1105,13 @@ }, "functions": { "covered": 0, - "total": 1, + "total": 3, "percentage": 0 }, "lines": { - "covered": 28, - "total": 61, - "percentage": 45.9 + "covered": 49, + "total": 122, + "percentage": 40.16 } }, "src/vs/platform/agentHost/common/codexSessionConfigKeys.ts": { @@ -1011,9 +1143,9 @@ "percentage": 88.09 }, "branches": { - "covered": 20, - "total": 29, - "percentage": 68.96 + "covered": 19, + "total": 28, + "percentage": 67.85 }, "functions": { "covered": 4, @@ -1028,9 +1160,9 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 224, - "total": 233, - "percentage": 96.13 + "covered": 253, + "total": 262, + "percentage": 96.56 }, "branches": { "covered": 7, @@ -1043,9 +1175,9 @@ "percentage": 66.66 }, "lines": { - "covered": 224, - "total": 233, - "percentage": 96.13 + "covered": 253, + "total": 262, + "percentage": 96.56 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -1160,14 +1292,14 @@ }, "src/vs/platform/agentHost/common/githubEndpoints.ts": { "statements": { - "covered": 106, + "covered": 120, "total": 130, - "percentage": 81.53 + "percentage": 92.3 }, "branches": { - "covered": 4, - "total": 14, - "percentage": 28.57 + "covered": 14, + "total": 23, + "percentage": 60.86 }, "functions": { "covered": 4, @@ -1175,53 +1307,9 @@ "percentage": 100 }, "lines": { - "covered": 106, + "covered": 120, "total": 130, - "percentage": 81.53 - } - }, - "src/vs/platform/agentHost/common/githubIssueReferences.ts": { - "statements": { - "covered": 55, - "total": 74, - "percentage": 74.32 - }, - "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 - }, - "functions": { - "covered": 1, - "total": 4, - "percentage": 25 - }, - "lines": { - "covered": 55, - "total": 74, - "percentage": 74.32 - } - }, - "src/vs/platform/agentHost/common/githubPullRequestReferences.ts": { - "statements": { - "covered": 38, - "total": 64, - "percentage": 59.37 - }, - "branches": { - "covered": 2, - "total": 5, - "percentage": 40 - }, - "functions": { - "covered": 2, - "total": 4, - "percentage": 50 - }, - "lines": { - "covered": 38, - "total": 64, - "percentage": 59.37 + "percentage": 92.3 } }, "src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts": { @@ -1248,9 +1336,9 @@ }, "src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts": { "statements": { - "covered": 69, - "total": 138, - "percentage": 50 + "covered": 70, + "total": 142, + "percentage": 49.29 }, "branches": { "covered": 3, @@ -1263,21 +1351,21 @@ "percentage": 40 }, "lines": { - "covered": 69, - "total": 138, - "percentage": 50 + "covered": 70, + "total": 142, + "percentage": 49.29 } }, "src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts": { "statements": { - "covered": 146, - "total": 219, - "percentage": 66.66 + "covered": 149, + "total": 225, + "percentage": 66.22 }, "branches": { "covered": 2, - "total": 7, - "percentage": 28.57 + "total": 8, + "percentage": 25 }, "functions": { "covered": 2, @@ -1285,9 +1373,9 @@ "percentage": 28.57 }, "lines": { - "covered": 146, - "total": 219, - "percentage": 66.66 + "covered": 149, + "total": 225, + "percentage": 66.22 } }, "src/vs/platform/agentHost/common/meta/agentCustomizationMeta.ts": { @@ -1312,6 +1400,28 @@ "percentage": 83.01 } }, + "src/vs/platform/agentHost/common/meta/agentDevContainerWorktreeMeta.ts": { + "statements": { + "covered": 25, + "total": 34, + "percentage": 73.52 + }, + "branches": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 25, + "total": 34, + "percentage": 73.52 + } + }, "src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts": { "statements": { "covered": 31, @@ -1358,24 +1468,24 @@ }, "src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts": { "statements": { - "covered": 183, - "total": 195, - "percentage": 93.84 + "covered": 199, + "total": 212, + "percentage": 93.86 }, "branches": { - "covered": 16, - "total": 29, - "percentage": 55.17 + "covered": 17, + "total": 35, + "percentage": 48.57 }, "functions": { - "covered": 7, - "total": 10, - "percentage": 70 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "lines": { - "covered": 183, - "total": 195, - "percentage": 93.84 + "covered": 199, + "total": 212, + "percentage": 93.86 } }, "src/vs/platform/agentHost/common/meta/agentFeedbackAttachments.ts": { @@ -1400,11 +1510,11 @@ "percentage": 38.75 } }, - "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "src/vs/platform/agentHost/common/meta/agentMergeMessageMeta.ts": { "statements": { - "covered": 20, - "total": 30, - "percentage": 66.66 + "covered": 23, + "total": 28, + "percentage": 82.14 }, "branches": { "covered": 0, @@ -1417,9 +1527,31 @@ "percentage": 0 }, "lines": { - "covered": 20, - "total": 30, - "percentage": 66.66 + "covered": 23, + "total": 28, + "percentage": 82.14 + } + }, + "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "statements": { + "covered": 50, + "total": 54, + "percentage": 92.59 + }, + "branches": { + "covered": 14, + "total": 19, + "percentage": 73.68 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 50, + "total": 54, + "percentage": 92.59 } }, "src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts": { @@ -1446,9 +1578,9 @@ }, "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { "statements": { - "covered": 27, - "total": 38, - "percentage": 71.05 + "covered": 37, + "total": 49, + "percentage": 75.51 }, "branches": { "covered": 2, @@ -1461,9 +1593,9 @@ "percentage": 0 }, "lines": { - "covered": 27, - "total": 38, - "percentage": 71.05 + "covered": 37, + "total": 49, + "percentage": 75.51 } }, "src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts": { @@ -1488,6 +1620,28 @@ "percentage": 84.81 } }, + "src/vs/platform/agentHost/common/meta/automationMeta.ts": { + "statements": { + "covered": 31, + "total": 35, + "percentage": 88.57 + }, + "branches": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "functions": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "lines": { + "covered": 31, + "total": 35, + "percentage": 88.57 + } + }, "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { "statements": { "covered": 39, @@ -1512,24 +1666,24 @@ }, "src/vs/platform/agentHost/common/openSessionLink.ts": { "statements": { - "covered": 120, - "total": 158, - "percentage": 75.94 + "covered": 132, + "total": 174, + "percentage": 75.86 }, "branches": { - "covered": 19, + "covered": 18, "total": 30, - "percentage": 63.33 + "percentage": 60 }, "functions": { - "covered": 4, - "total": 10, - "percentage": 40 + "covered": 5, + "total": 12, + "percentage": 41.66 }, "lines": { - "covered": 120, - "total": 158, - "percentage": 75.94 + "covered": 132, + "total": 174, + "percentage": 75.86 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1600,14 +1754,14 @@ }, "src/vs/platform/agentHost/common/pendingRequestRegistry.ts": { "statements": { - "covered": 148, + "covered": 152, "total": 168, - "percentage": 88.09 + "percentage": 90.47 }, "branches": { - "covered": 19, + "covered": 20, "total": 24, - "percentage": 79.16 + "percentage": 83.33 }, "functions": { "covered": 11, @@ -1615,9 +1769,9 @@ "percentage": 91.66 }, "lines": { - "covered": 148, + "covered": 152, "total": 168, - "percentage": 88.09 + "percentage": 90.47 } }, "src/vs/platform/agentHost/common/reasoningEffort.ts": { @@ -1649,9 +1803,9 @@ "percentage": 85.18 }, "branches": { - "covered": 6, - "total": 8, - "percentage": 75 + "covered": 7, + "total": 9, + "percentage": 77.77 }, "functions": { "covered": 2, @@ -1710,8 +1864,8 @@ }, "src/vs/platform/agentHost/common/serverToolNames.ts": { "statements": { - "covered": 35, - "total": 35, + "covered": 46, + "total": 46, "percentage": 100 }, "branches": { @@ -1725,53 +1879,53 @@ "percentage": 100 }, "lines": { - "covered": 35, - "total": 35, + "covered": 46, + "total": 46, "percentage": 100 } }, "src/vs/platform/agentHost/common/sessionArtifactCollection.ts": { "statements": { - "covered": 63, - "total": 148, - "percentage": 42.56 + "covered": 149, + "total": 169, + "percentage": 88.16 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 13, + "total": 27, + "percentage": 48.14 }, "functions": { - "covered": 0, - "total": 8, - "percentage": 0 + "covered": 9, + "total": 9, + "percentage": 100 }, "lines": { - "covered": 63, - "total": 148, - "percentage": 42.56 + "covered": 149, + "total": 169, + "percentage": 88.16 } }, "src/vs/platform/agentHost/common/sessionArtifacts.ts": { "statements": { - "covered": 85, - "total": 145, - "percentage": 58.62 + "covered": 156, + "total": 181, + "percentage": 86.18 }, "branches": { - "covered": 3, - "total": 6, - "percentage": 50 + "covered": 15, + "total": 26, + "percentage": 57.69 }, "functions": { - "covered": 2, + "covered": 7, "total": 8, - "percentage": 25 + "percentage": 87.5 }, "lines": { - "covered": 85, - "total": 145, - "percentage": 58.62 + "covered": 156, + "total": 181, + "percentage": 86.18 } }, "src/vs/platform/agentHost/common/sessionConfigKeys.ts": { @@ -1798,8 +1952,8 @@ }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 484, - "total": 484, + "covered": 497, + "total": 497, "percentage": 100 }, "branches": { @@ -1813,8 +1967,8 @@ "percentage": 100 }, "lines": { - "covered": 484, - "total": 484, + "covered": 497, + "total": 497, "percentage": 100 } }, @@ -1842,24 +1996,24 @@ }, "src/vs/platform/agentHost/common/state/agentSubscription.ts": { "statements": { - "covered": 588, - "total": 1219, - "percentage": 48.23 + "covered": 628, + "total": 1286, + "percentage": 48.83 }, "branches": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 10, + "total": 13, + "percentage": 76.92 }, "functions": { "covered": 1, - "total": 84, - "percentage": 1.19 + "total": 93, + "percentage": 1.07 }, "lines": { - "covered": 588, - "total": 1219, - "percentage": 48.23 + "covered": 628, + "total": 1286, + "percentage": 48.83 } }, "src/vs/platform/agentHost/common/state/chatAttachmentContext.ts": { @@ -1884,10 +2038,32 @@ "percentage": 91.72 } }, + "src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts": { + "statements": { + "covered": 40, + "total": 87, + "percentage": 45.97 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 4, + "percentage": 0 + }, + "lines": { + "covered": 40, + "total": 87, + "percentage": 45.97 + } + }, "src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts": { "statements": { - "covered": 419, - "total": 419, + "covered": 422, + "total": 422, "percentage": 100 }, "branches": { @@ -1901,8 +2077,8 @@ "percentage": 100 }, "lines": { - "covered": 419, - "total": 419, + "covered": 422, + "total": 422, "percentage": 100 } }, @@ -1974,24 +2150,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts": { "statements": { - "covered": 15, + "covered": 43, "total": 48, - "percentage": 31.25 + "percentage": 89.58 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 7, + "total": 11, + "percentage": 63.63 }, "functions": { - "covered": 0, + "covered": 1, "total": 1, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 15, + "covered": 43, "total": 48, - "percentage": 31.25 + "percentage": 89.58 } }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { @@ -2018,24 +2194,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 685, - "total": 864, - "percentage": 79.28 + "covered": 695, + "total": 906, + "percentage": 76.71 }, "branches": { - "covered": 165, - "total": 232, - "percentage": 71.12 + "covered": 168, + "total": 237, + "percentage": 70.88 }, "functions": { - "covered": 15, - "total": 15, - "percentage": 100 + "covered": 16, + "total": 17, + "percentage": 94.11 }, "lines": { - "covered": 685, - "total": 864, - "percentage": 79.28 + "covered": 695, + "total": 906, + "percentage": 76.71 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -2150,8 +2326,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/actions.ts": { "statements": { - "covered": 260, - "total": 260, + "covered": 263, + "total": 263, "percentage": 100 }, "branches": { @@ -2165,15 +2341,15 @@ "percentage": 100 }, "lines": { - "covered": 260, - "total": 260, + "covered": 263, + "total": 263, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/protocol/common/commands.ts": { "statements": { - "covered": 1157, - "total": 1157, + "covered": 1172, + "total": 1172, "percentage": 100 }, "branches": { @@ -2187,8 +2363,8 @@ "percentage": 100 }, "lines": { - "covered": 1157, - "total": 1157, + "covered": 1172, + "total": 1172, "percentage": 100 } }, @@ -2216,8 +2392,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/notifications.ts": { "statements": { - "covered": 67, - "total": 67, + "covered": 68, + "total": 68, "percentage": 100 }, "branches": { @@ -2231,8 +2407,8 @@ "percentage": 100 }, "lines": { - "covered": 67, - "total": 67, + "covered": 68, + "total": 68, "percentage": 100 } }, @@ -2398,8 +2574,8 @@ }, "branches": { "covered": 9, - "total": 14, - "percentage": 64.28 + "total": 13, + "percentage": 69.23 }, "functions": { "covered": 3, @@ -2414,9 +2590,9 @@ }, "src/vs/platform/agentHost/common/state/protocol/version/registry.ts": { "statements": { - "covered": 216, - "total": 222, - "percentage": 97.29 + "covered": 217, + "total": 223, + "percentage": 97.3 }, "branches": { "covered": 2, @@ -2429,9 +2605,9 @@ "percentage": 50 }, "lines": { - "covered": 216, - "total": 222, - "percentage": 97.29 + "covered": 217, + "total": 223, + "percentage": 97.3 } }, "src/vs/platform/agentHost/common/state/protocolUpgrade.ts": { @@ -2458,31 +2634,31 @@ }, "src/vs/platform/agentHost/common/state/sessionActions.ts": { "statements": { - "covered": 231, - "total": 231, + "covered": 266, + "total": 266, "percentage": 100 }, "branches": { - "covered": 6, - "total": 6, + "covered": 10, + "total": 10, "percentage": 100 }, "functions": { - "covered": 6, - "total": 6, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 231, - "total": 231, + "covered": 266, + "total": 266, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/sessionProtocol.ts": { "statements": { - "covered": 152, - "total": 154, - "percentage": 98.7 + "covered": 158, + "total": 160, + "percentage": 98.75 }, "branches": { "covered": 5, @@ -2495,9 +2671,9 @@ "percentage": 75 }, "lines": { - "covered": 152, - "total": 154, - "percentage": 98.7 + "covered": 158, + "total": 160, + "percentage": 98.75 } }, "src/vs/platform/agentHost/common/state/sessionReducers.ts": { @@ -2524,31 +2700,31 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1650, - "total": 2060, - "percentage": 80.09 + "covered": 1764, + "total": 2202, + "percentage": 80.1 }, "branches": { - "covered": 234, - "total": 339, - "percentage": 69.02 + "covered": 281, + "total": 398, + "percentage": 70.6 }, "functions": { - "covered": 70, - "total": 96, - "percentage": 72.91 + "covered": 78, + "total": 107, + "percentage": 72.89 }, "lines": { - "covered": 1650, - "total": 2060, - "percentage": 80.09 + "covered": 1764, + "total": 2202, + "percentage": 80.1 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { "statements": { - "covered": 49, - "total": 73, - "percentage": 67.12 + "covered": 61, + "total": 109, + "percentage": 55.96 }, "branches": { "covered": 4, @@ -2561,9 +2737,9 @@ "percentage": 60 }, "lines": { - "covered": 49, - "total": 73, - "percentage": 67.12 + "covered": 61, + "total": 109, + "percentage": 55.96 } }, "src/vs/platform/agentHost/common/streamingToolCallDisplay.ts": { @@ -2610,6 +2786,50 @@ "percentage": 100 } }, + "src/vs/platform/agentHost/common/workspacelessScratchDir.ts": { + "statements": { + "covered": 11, + "total": 11, + "percentage": 100 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 11, + "total": 11, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/worktreePaths.ts": { + "statements": { + "covered": 35, + "total": 39, + "percentage": 89.74 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 35, + "total": 39, + "percentage": 89.74 + } + }, "src/vs/platform/agentHost/node/activeClientState.ts": { "statements": { "covered": 160, @@ -2678,31 +2898,31 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 407, - "total": 442, - "percentage": 92.08 + "covered": 372, + "total": 402, + "percentage": 92.53 }, "branches": { - "covered": 46, - "total": 62, - "percentage": 74.19 + "covered": 48, + "total": 61, + "percentage": 78.68 }, "functions": { - "covered": 18, - "total": 19, - "percentage": 94.73 + "covered": 16, + "total": 17, + "percentage": 94.11 }, "lines": { - "covered": 407, - "total": 442, - "percentage": 92.08 + "covered": 372, + "total": 402, + "percentage": 92.53 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { "statements": { - "covered": 129, - "total": 180, - "percentage": 71.66 + "covered": 136, + "total": 187, + "percentage": 72.72 }, "branches": { "covered": 20, @@ -2715,9 +2935,31 @@ "percentage": 85.71 }, "lines": { - "covered": 129, - "total": 180, - "percentage": 71.66 + "covered": 136, + "total": 187, + "percentage": 72.72 + } + }, + "src/vs/platform/agentHost/node/agentHostAutomationService.ts": { + "statements": { + "covered": 566, + "total": 1099, + "percentage": 51.5 + }, + "branches": { + "covered": 106, + "total": 158, + "percentage": 67.08 + }, + "functions": { + "covered": 38, + "total": 61, + "percentage": 62.29 + }, + "lines": { + "covered": 566, + "total": 1099, + "percentage": 51.5 } }, "src/vs/platform/agentHost/node/agentHostBangCommand.ts": { @@ -2744,36 +2986,36 @@ }, "src/vs/platform/agentHost/node/agentHostBootstrap.ts": { "statements": { - "covered": 184, - "total": 191, - "percentage": 96.33 + "covered": 211, + "total": 216, + "percentage": 97.68 }, "branches": { - "covered": 6, - "total": 9, - "percentage": 66.66 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "functions": { - "covered": 2, - "total": 2, - "percentage": 100 + "covered": 5, + "total": 6, + "percentage": 83.33 }, "lines": { - "covered": 184, - "total": 191, - "percentage": 96.33 + "covered": 211, + "total": 216, + "percentage": 97.68 } }, "src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts": { "statements": { - "covered": 343, - "total": 367, - "percentage": 93.46 + "covered": 336, + "total": 360, + "percentage": 93.33 }, "branches": { - "covered": 52, - "total": 58, - "percentage": 89.65 + "covered": 53, + "total": 59, + "percentage": 89.83 }, "functions": { "covered": 15, @@ -2781,21 +3023,21 @@ "percentage": 93.75 }, "lines": { - "covered": 343, - "total": 367, - "percentage": 93.46 + "covered": 336, + "total": 360, + "percentage": 93.33 } }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 378, + "covered": 386, "total": 440, - "percentage": 85.9 + "percentage": 87.72 }, "branches": { - "covered": 58, - "total": 81, - "percentage": 71.6 + "covered": 66, + "total": 88, + "percentage": 75 }, "functions": { "covered": 29, @@ -2803,53 +3045,53 @@ "percentage": 96.66 }, "lines": { - "covered": 378, + "covered": 386, "total": 440, - "percentage": 85.9 + "percentage": 87.72 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 265, - "total": 296, - "percentage": 89.52 + "covered": 284, + "total": 317, + "percentage": 89.58 }, "branches": { - "covered": 53, - "total": 69, - "percentage": 76.81 + "covered": 60, + "total": 77, + "percentage": 77.92 }, "functions": { - "covered": 13, - "total": 13, + "covered": 14, + "total": 14, "percentage": 100 }, "lines": { - "covered": 265, - "total": 296, - "percentage": 89.52 + "covered": 284, + "total": 317, + "percentage": 89.58 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1241, - "total": 1653, - "percentage": 75.07 + "covered": 1250, + "total": 1632, + "percentage": 76.59 }, "branches": { - "covered": 189, - "total": 260, - "percentage": 72.69 + "covered": 197, + "total": 270, + "percentage": 72.96 }, "functions": { - "covered": 56, + "covered": 57, "total": 70, - "percentage": 80 + "percentage": 81.42 }, "lines": { - "covered": 1241, - "total": 1653, - "percentage": 75.07 + "covered": 1250, + "total": 1632, + "percentage": 76.59 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2876,14 +3118,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts": { "statements": { - "covered": 42, - "total": 44, - "percentage": 95.45 + "covered": 52, + "total": 55, + "percentage": 94.54 }, "branches": { - "covered": 10, - "total": 11, - "percentage": 90.9 + "covered": 12, + "total": 13, + "percentage": 92.3 }, "functions": { "covered": 5, @@ -2891,9 +3133,9 @@ "percentage": 100 }, "lines": { - "covered": 42, - "total": 44, - "percentage": 95.45 + "covered": 52, + "total": 55, + "percentage": 94.54 } }, "src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts": { @@ -2940,16 +3182,38 @@ "percentage": 94.44 } }, + "src/vs/platform/agentHost/node/agentHostChatContributionsService.ts": { + "statements": { + "covered": 264, + "total": 294, + "percentage": 89.79 + }, + "branches": { + "covered": 64, + "total": 81, + "percentage": 79.01 + }, + "functions": { + "covered": 22, + "total": 23, + "percentage": 95.65 + }, + "lines": { + "covered": 264, + "total": 294, + "percentage": 89.79 + } + }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { "statements": { - "covered": 360, + "covered": 366, "total": 508, - "percentage": 70.86 + "percentage": 72.04 }, "branches": { - "covered": 77, - "total": 107, - "percentage": 71.96 + "covered": 96, + "total": 123, + "percentage": 78.04 }, "functions": { "covered": 17, @@ -2957,9 +3221,9 @@ "percentage": 80.95 }, "lines": { - "covered": 360, + "covered": 366, "total": 508, - "percentage": 70.86 + "percentage": 72.04 } }, "src/vs/platform/agentHost/node/agentHostClientConnectionService.ts": { @@ -3008,14 +3272,14 @@ }, "src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts": { "statements": { - "covered": 58, - "total": 58, - "percentage": 100 + "covered": 60, + "total": 62, + "percentage": 96.77 }, "branches": { - "covered": 13, - "total": 14, - "percentage": 92.85 + "covered": 12, + "total": 16, + "percentage": 75 }, "functions": { "covered": 6, @@ -3023,9 +3287,9 @@ "percentage": 100 }, "lines": { - "covered": 58, - "total": 58, - "percentage": 100 + "covered": 60, + "total": 62, + "percentage": 96.77 } }, "src/vs/platform/agentHost/node/agentHostCompletions.ts": { @@ -3052,9 +3316,9 @@ }, "src/vs/platform/agentHost/node/agentHostContributions.ts": { "statements": { - "covered": 47, - "total": 50, - "percentage": 94 + "covered": 50, + "total": 53, + "percentage": 94.33 }, "branches": { "covered": 2, @@ -3067,53 +3331,53 @@ "percentage": 100 }, "lines": { - "covered": 47, - "total": 50, - "percentage": 94 + "covered": 50, + "total": 53, + "percentage": 94.33 } }, "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { "statements": { - "covered": 580, - "total": 734, - "percentage": 79.01 + "covered": 566, + "total": 720, + "percentage": 78.61 }, "branches": { - "covered": 107, - "total": 148, - "percentage": 72.29 + "covered": 106, + "total": 147, + "percentage": 72.1 }, "functions": { - "covered": 43, - "total": 48, - "percentage": 89.58 + "covered": 41, + "total": 46, + "percentage": 89.13 }, "lines": { - "covered": 580, - "total": 734, - "percentage": 79.01 + "covered": 566, + "total": 720, + "percentage": 78.61 } }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 327, - "total": 426, - "percentage": 76.76 + "covered": 370, + "total": 450, + "percentage": 82.22 }, "branches": { - "covered": 47, - "total": 63, - "percentage": 74.6 + "covered": 53, + "total": 70, + "percentage": 75.71 }, "functions": { - "covered": 24, - "total": 32, - "percentage": 75 + "covered": 27, + "total": 33, + "percentage": 81.81 }, "lines": { - "covered": 327, - "total": 426, - "percentage": 76.76 + "covered": 370, + "total": 450, + "percentage": 82.22 } }, "src/vs/platform/agentHost/node/agentHostDebugLogs.ts": { @@ -3123,9 +3387,9 @@ "percentage": 87.38 }, "branches": { - "covered": 28, - "total": 40, - "percentage": 70 + "covered": 30, + "total": 42, + "percentage": 71.42 }, "functions": { "covered": 9, @@ -3228,14 +3492,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 169, + "covered": 165, "total": 185, - "percentage": 91.35 + "percentage": 89.18 }, "branches": { - "covered": 28, - "total": 37, - "percentage": 75.67 + "covered": 22, + "total": 34, + "percentage": 64.7 }, "functions": { "covered": 14, @@ -3243,21 +3507,21 @@ "percentage": 100 }, "lines": { - "covered": 169, + "covered": 165, "total": 185, - "percentage": 91.35 + "percentage": 89.18 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { "statements": { - "covered": 121, + "covered": 125, "total": 127, - "percentage": 95.27 + "percentage": 98.42 }, "branches": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 13, + "total": 13, + "percentage": 100 }, "functions": { "covered": 7, @@ -3265,9 +3529,9 @@ "percentage": 87.5 }, "lines": { - "covered": 121, + "covered": 125, "total": 127, - "percentage": 95.27 + "percentage": 98.42 } }, "src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts": { @@ -3294,46 +3558,46 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 1262, - "total": 1736, - "percentage": 72.69 + "covered": 1274, + "total": 1744, + "percentage": 73.05 }, "branches": { - "covered": 273, - "total": 385, - "percentage": 70.9 + "covered": 286, + "total": 392, + "percentage": 72.95 }, "functions": { - "covered": 63, - "total": 80, - "percentage": 78.75 + "covered": 64, + "total": 81, + "percentage": 79.01 }, "lines": { - "covered": 1262, - "total": 1736, - "percentage": 72.69 + "covered": 1274, + "total": 1744, + "percentage": 73.05 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 234, - "total": 426, - "percentage": 54.92 + "covered": 264, + "total": 394, + "percentage": 67 }, "branches": { - "covered": 62, - "total": 87, - "percentage": 71.26 + "covered": 77, + "total": 96, + "percentage": 80.2 }, "functions": { "covered": 9, - "total": 14, - "percentage": 64.28 + "total": 13, + "percentage": 69.23 }, "lines": { - "covered": 234, - "total": 426, - "percentage": 54.92 + "covered": 264, + "total": 394, + "percentage": 67 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -3365,9 +3629,9 @@ "percentage": 89.02 }, "branches": { - "covered": 47, - "total": 57, - "percentage": 82.45 + "covered": 49, + "total": 59, + "percentage": 83.05 }, "functions": { "covered": 13, @@ -3382,24 +3646,24 @@ }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { "statements": { - "covered": 130, - "total": 161, - "percentage": 80.74 + "covered": 161, + "total": 193, + "percentage": 83.41 }, "branches": { - "covered": 15, - "total": 24, - "percentage": 62.5 + "covered": 19, + "total": 29, + "percentage": 65.51 }, "functions": { - "covered": 9, - "total": 11, - "percentage": 81.81 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "lines": { - "covered": 130, - "total": 161, - "percentage": 80.74 + "covered": 161, + "total": 193, + "percentage": 83.41 } }, "src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts": { @@ -3453,9 +3717,9 @@ "percentage": 81 }, "branches": { - "covered": 24, - "total": 32, - "percentage": 75 + "covered": 25, + "total": 33, + "percentage": 75.75 }, "functions": { "covered": 5, @@ -3534,6 +3798,28 @@ "percentage": 96.66 } }, + "src/vs/platform/agentHost/node/agentHostProviderService.ts": { + "statements": { + "covered": 191, + "total": 233, + "percentage": 81.97 + }, + "branches": { + "covered": 30, + "total": 45, + "percentage": 66.66 + }, + "functions": { + "covered": 12, + "total": 15, + "percentage": 80 + }, + "lines": { + "covered": 191, + "total": 233, + "percentage": 81.97 + } + }, "src/vs/platform/agentHost/node/agentHostProxyResolver.ts": { "statements": { "covered": 158, @@ -3556,11 +3842,33 @@ "percentage": 73.83 } }, + "src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts": { + "statements": { + "covered": 75, + "total": 196, + "percentage": 38.26 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 8, + "percentage": 12.5 + }, + "lines": { + "covered": 75, + "total": 196, + "percentage": 38.26 + } + }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { "statements": { - "covered": 127, - "total": 497, - "percentage": 25.55 + "covered": 137, + "total": 557, + "percentage": 24.59 }, "branches": { "covered": 1, @@ -3569,35 +3877,57 @@ }, "functions": { "covered": 1, - "total": 15, - "percentage": 6.66 + "total": 16, + "percentage": 6.25 }, "lines": { - "covered": 127, - "total": 497, - "percentage": 25.55 + "covered": 137, + "total": 557, + "percentage": 24.59 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts": { "statements": { - "covered": 66, - "total": 117, - "percentage": 56.41 + "covered": 200, + "total": 273, + "percentage": 73.26 }, "branches": { - "covered": 14, - "total": 18, - "percentage": 77.77 + "covered": 37, + "total": 41, + "percentage": 90.24 }, "functions": { - "covered": 4, - "total": 8, - "percentage": 50 + "covered": 7, + "total": 12, + "percentage": 58.33 }, "lines": { - "covered": 66, - "total": 117, - "percentage": 56.41 + "covered": 200, + "total": 273, + "percentage": 73.26 + } + }, + "src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts": { + "statements": { + "covered": 252, + "total": 506, + "percentage": 49.8 + }, + "branches": { + "covered": 27, + "total": 40, + "percentage": 67.5 + }, + "functions": { + "covered": 7, + "total": 24, + "percentage": 29.16 + }, + "lines": { + "covered": 252, + "total": 506, + "percentage": 49.8 } }, "src/vs/platform/agentHost/node/agentHostRenameCommand.ts": { @@ -3690,14 +4020,14 @@ }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { "statements": { - "covered": 209, + "covered": 211, "total": 264, - "percentage": 79.16 + "percentage": 79.92 }, "branches": { - "covered": 35, - "total": 52, - "percentage": 67.3 + "covered": 38, + "total": 54, + "percentage": 70.37 }, "functions": { "covered": 9, @@ -3705,16 +4035,16 @@ "percentage": 69.23 }, "lines": { - "covered": 209, + "covered": 211, "total": 264, - "percentage": 79.16 + "percentage": 79.92 } }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 351, - "total": 401, - "percentage": 87.53 + "covered": 354, + "total": 402, + "percentage": 88.05 }, "branches": { "covered": 23, @@ -3727,47 +4057,69 @@ "percentage": 85.71 }, "lines": { - "covered": 351, - "total": 401, - "percentage": 87.53 + "covered": 354, + "total": 402, + "percentage": 88.05 } }, "src/vs/platform/agentHost/node/agentHostServices.ts": { "statements": { - "covered": 161, - "total": 165, - "percentage": 97.57 + "covered": 130, + "total": 130, + "percentage": 100 }, "branches": { - "covered": 10, - "total": 13, - "percentage": 76.92 + "covered": 2, + "total": 3, + "percentage": 66.66 }, "functions": { - "covered": 7, - "total": 7, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 161, - "total": 165, - "percentage": 97.57 + "covered": 130, + "total": 130, + "percentage": 100 } }, - "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { + "src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts": { "statements": { - "covered": 87, - "total": 95, - "percentage": 91.57 + "covered": 279, + "total": 306, + "percentage": 91.17 }, "branches": { - "covered": 10, - "total": 12, - "percentage": 83.33 + "covered": 56, + "total": 66, + "percentage": 84.84 }, "functions": { - "covered": 1, - "total": 1, + "covered": 19, + "total": 19, + "percentage": 100 + }, + "lines": { + "covered": 279, + "total": 306, + "percentage": 91.17 + } + }, + "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { + "statements": { + "covered": 87, + "total": 95, + "percentage": 91.57 + }, + "branches": { + "covered": 10, + "total": 12, + "percentage": 83.33 + }, + "functions": { + "covered": 1, + "total": 1, "percentage": 100 }, "lines": { @@ -3778,24 +4130,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 638, - "total": 894, - "percentage": 71.36 + "covered": 699, + "total": 914, + "percentage": 76.47 }, "branches": { - "covered": 106, - "total": 155, - "percentage": 68.38 + "covered": 130, + "total": 181, + "percentage": 71.82 }, "functions": { - "covered": 31, + "covered": 34, "total": 46, - "percentage": 67.39 + "percentage": 73.91 }, "lines": { - "covered": 638, - "total": 894, - "percentage": 71.36 + "covered": 699, + "total": 914, + "percentage": 76.47 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -3842,6 +4194,28 @@ "percentage": 85.71 } }, + "src/vs/platform/agentHost/node/agentHostShutdown.ts": { + "statements": { + "covered": 22, + "total": 25, + "percentage": 88 + }, + "branches": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 22, + "total": 25, + "percentage": 88 + } + }, "src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts": { "statements": { "covered": 68, @@ -3888,46 +4262,68 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1730, - "total": 1927, - "percentage": 89.77 + "covered": 1828, + "total": 2059, + "percentage": 88.78 }, "branches": { - "covered": 276, - "total": 334, - "percentage": 82.63 + "covered": 305, + "total": 369, + "percentage": 82.65 }, "functions": { - "covered": 77, - "total": 89, - "percentage": 86.51 + "covered": 83, + "total": 96, + "percentage": 86.45 }, "lines": { - "covered": 1730, - "total": 1927, - "percentage": 89.77 + "covered": 1828, + "total": 2059, + "percentage": 88.78 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { "statements": { - "covered": 110, - "total": 121, - "percentage": 90.9 + "covered": 141, + "total": 169, + "percentage": 83.43 }, "branches": { - "covered": 17, - "total": 23, - "percentage": 73.91 + "covered": 22, + "total": 29, + "percentage": 75.86 }, "functions": { - "covered": 10, - "total": 10, + "covered": 13, + "total": 13, "percentage": 100 }, "lines": { - "covered": 110, - "total": 121, - "percentage": 90.9 + "covered": 141, + "total": 169, + "percentage": 83.43 + } + }, + "src/vs/platform/agentHost/node/agentHostSubscriptionService.ts": { + "statements": { + "covered": 52, + "total": 56, + "percentage": 92.85 + }, + "branches": { + "covered": 13, + "total": 13, + "percentage": 100 + }, + "functions": { + "covered": 4, + "total": 6, + "percentage": 66.66 + }, + "lines": { + "covered": 52, + "total": 56, + "percentage": 92.85 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { @@ -3976,46 +4372,46 @@ }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1176, - "total": 1338, - "percentage": 87.89 + "covered": 1248, + "total": 1412, + "percentage": 88.38 }, "branches": { - "covered": 66, - "total": 96, - "percentage": 68.75 + "covered": 68, + "total": 100, + "percentage": 68 }, "functions": { - "covered": 16, - "total": 23, - "percentage": 69.56 + "covered": 17, + "total": 24, + "percentage": 70.83 }, "lines": { - "covered": 1176, - "total": 1338, - "percentage": 87.89 + "covered": 1248, + "total": 1412, + "percentage": 88.38 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { - "covered": 198, - "total": 287, - "percentage": 68.98 + "covered": 209, + "total": 295, + "percentage": 70.84 }, "branches": { - "covered": 18, - "total": 47, - "percentage": 38.29 + "covered": 21, + "total": 51, + "percentage": 41.17 }, "functions": { - "covered": 15, + "covered": 16, "total": 30, - "percentage": 50 + "percentage": 53.33 }, "lines": { - "covered": 198, - "total": 287, - "percentage": 68.98 + "covered": 209, + "total": 295, + "percentage": 70.84 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -4025,9 +4421,9 @@ "percentage": 90.78 }, "branches": { - "covered": 120, - "total": 151, - "percentage": 79.47 + "covered": 121, + "total": 152, + "percentage": 79.6 }, "functions": { "covered": 35, @@ -4042,36 +4438,80 @@ }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 266, - "total": 309, - "percentage": 86.08 + "covered": 241, + "total": 283, + "percentage": 85.15 }, "branches": { - "covered": 55, - "total": 62, - "percentage": 88.7 + "covered": 45, + "total": 51, + "percentage": 88.23 }, "functions": { - "covered": 15, - "total": 16, - "percentage": 93.75 + "covered": 13, + "total": 13, + "percentage": 100 }, "lines": { - "covered": 266, - "total": 309, - "percentage": 86.08 + "covered": 241, + "total": 283, + "percentage": 85.15 + } + }, + "src/vs/platform/agentHost/node/agentHostTurnStarter.ts": { + "statements": { + "covered": 90, + "total": 123, + "percentage": 73.17 + }, + "branches": { + "covered": 9, + "total": 12, + "percentage": 75 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 90, + "total": 123, + "percentage": 73.17 + } + }, + "src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts": { + "statements": { + "covered": 56, + "total": 57, + "percentage": 98.24 + }, + "branches": { + "covered": 22, + "total": 25, + "percentage": 88 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 56, + "total": 57, + "percentage": 98.24 } }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { "statements": { - "covered": 474, - "total": 606, - "percentage": 78.21 + "covered": 490, + "total": 622, + "percentage": 78.77 }, "branches": { - "covered": 48, - "total": 67, - "percentage": 71.64 + "covered": 49, + "total": 68, + "percentage": 72.05 }, "functions": { "covered": 25, @@ -4079,9 +4519,9 @@ "percentage": 80.64 }, "lines": { - "covered": 474, - "total": 606, - "percentage": 78.21 + "covered": 490, + "total": 622, + "percentage": 78.77 } }, "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { @@ -4130,24 +4570,24 @@ }, "src/vs/platform/agentHost/node/agentMergeController.ts": { "statements": { - "covered": 329, - "total": 1020, - "percentage": 32.25 + "covered": 393, + "total": 1127, + "percentage": 34.87 }, "branches": { - "covered": 23, - "total": 49, - "percentage": 46.93 + "covered": 31, + "total": 60, + "percentage": 51.66 }, "functions": { "covered": 13, - "total": 49, - "percentage": 26.53 + "total": 48, + "percentage": 27.08 }, "lines": { - "covered": 329, - "total": 1020, - "percentage": 32.25 + "covered": 393, + "total": 1127, + "percentage": 34.87 } }, "src/vs/platform/agentHost/node/agentMergeTools.ts": { @@ -4196,24 +4636,24 @@ }, "src/vs/platform/agentHost/node/agentPeerChats.ts": { "statements": { - "covered": 204, - "total": 390, - "percentage": 52.3 + "covered": 128, + "total": 194, + "percentage": 65.97 }, "branches": { - "covered": 17, - "total": 50, - "percentage": 34 + "covered": 4, + "total": 15, + "percentage": 26.66 }, "functions": { - "covered": 7, - "total": 27, - "percentage": 25.92 + "covered": 4, + "total": 19, + "percentage": 21.05 }, "lines": { - "covered": 204, - "total": 390, - "percentage": 52.3 + "covered": 128, + "total": 194, + "percentage": 65.97 } }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { @@ -4306,119 +4746,559 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 5038, - "total": 6935, - "percentage": 72.64 + "covered": 5362, + "total": 7218, + "percentage": 74.28 }, "branches": { - "covered": 890, - "total": 1358, - "percentage": 65.53 + "covered": 1021, + "total": 1509, + "percentage": 67.66 }, "functions": { - "covered": 213, - "total": 283, - "percentage": 75.26 + "covered": 250, + "total": 318, + "percentage": 78.61 }, "lines": { - "covered": 5038, - "total": 6935, - "percentage": 72.64 + "covered": 5362, + "total": 7218, + "percentage": 74.28 } }, "src/vs/platform/agentHost/node/agentServiceComposition.ts": { "statements": { - "covered": 206, - "total": 224, - "percentage": 91.96 + "covered": 180, + "total": 196, + "percentage": 91.83 }, "branches": { - "covered": 12, - "total": 20, - "percentage": 60 + "covered": 7, + "total": 13, + "percentage": 53.84 }, "functions": { - "covered": 10, - "total": 16, - "percentage": 62.5 + "covered": 5, + "total": 9, + "percentage": 55.55 }, "lines": { - "covered": 206, - "total": 224, - "percentage": 91.96 + "covered": 180, + "total": 196, + "percentage": 91.83 } }, "src/vs/platform/agentHost/node/agentServiceFoundation.ts": { "statements": { - "covered": 139, - "total": 152, - "percentage": 91.44 + "covered": 142, + "total": 155, + "percentage": 91.61 }, "branches": { - "covered": 17, - "total": 21, - "percentage": 80.95 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "functions": { - "covered": 17, - "total": 24, - "percentage": 70.83 + "covered": 19, + "total": 27, + "percentage": 70.37 }, "lines": { - "covered": 139, - "total": 152, - "percentage": 91.44 + "covered": 142, + "total": 155, + "percentage": 91.61 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 166, - "total": 212, - "percentage": 78.3 + "covered": 197, + "total": 221, + "percentage": 89.14 }, "branches": { - "covered": 15, - "total": 19, - "percentage": 78.94 + "covered": 21, + "total": 27, + "percentage": 77.77 }, "functions": { - "covered": 10, - "total": 16, - "percentage": 62.5 + "covered": 13, + "total": 17, + "percentage": 76.47 }, "lines": { - "covered": 166, - "total": 212, - "percentage": 78.3 + "covered": 197, + "total": 221, + "percentage": 89.14 + } + }, + "src/vs/platform/agentHost/node/agentSessionResidency.ts": { + "statements": { + "covered": 238, + "total": 277, + "percentage": 85.92 + }, + "branches": { + "covered": 54, + "total": 66, + "percentage": 81.81 + }, + "functions": { + "covered": 12, + "total": 13, + "percentage": 92.3 + }, + "lines": { + "covered": 238, + "total": 277, + "percentage": 85.92 + } + }, + "src/vs/platform/agentHost/node/agentSideEffects.ts": { + "statements": { + "covered": 1602, + "total": 1900, + "percentage": 84.31 + }, + "branches": { + "covered": 295, + "total": 399, + "percentage": 73.93 + }, + "functions": { + "covered": 48, + "total": 54, + "percentage": 88.88 + }, + "lines": { + "covered": 1602, + "total": 1900, + "percentage": 84.31 + } + }, + "src/vs/platform/agentHost/node/appNodeModules.ts": { + "statements": { + "covered": 25, + "total": 27, + "percentage": 92.59 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 25, + "total": 27, + "percentage": 92.59 + } + }, + "src/vs/platform/agentHost/node/automationCron.ts": { + "statements": { + "covered": 67, + "total": 239, + "percentage": 28.03 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 11, + "percentage": 0 + }, + "lines": { + "covered": 67, + "total": 239, + "percentage": 28.03 + } + }, + "src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts": { + "statements": { + "covered": 117, + "total": 204, + "percentage": 57.35 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 13, + "percentage": 7.69 + }, + "lines": { + "covered": 117, + "total": 204, + "percentage": 57.35 + } + }, + "src/vs/platform/agentHost/node/chatContributions/artifactTools/artifactToolsContribution.ts": { + "statements": { + "covered": 30, + "total": 30, + "percentage": 100 + }, + "branches": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 30, + "total": 30, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts": { + "statements": { + "covered": 49, + "total": 49, + "percentage": 100 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 49, + "total": 49, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts": { + "statements": { + "covered": 71, + "total": 80, + "percentage": 88.75 + }, + "branches": { + "covered": 8, + "total": 13, + "percentage": 61.53 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 71, + "total": 80, + "percentage": 88.75 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatSurface/chatSurfaceContribution.ts": { + "statements": { + "covered": 31, + "total": 33, + "percentage": 93.93 + }, + "branches": { + "covered": 2, + "total": 7, + "percentage": 28.57 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 31, + "total": 33, + "percentage": 93.93 + } + }, + "src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts": { + "statements": { + "covered": 51, + "total": 59, + "percentage": 86.44 + }, + "branches": { + "covered": 13, + "total": 14, + "percentage": 92.85 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 51, + "total": 59, + "percentage": 86.44 + } + }, + "src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts": { + "statements": { + "covered": 32, + "total": 32, + "percentage": 100 + }, + "branches": { + "covered": 3, + "total": 4, + "percentage": 75 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 32, + "total": 32, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts": { + "statements": { + "covered": 42, + "total": 42, + "percentage": 100 + }, + "branches": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 42, + "total": 42, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts": { + "statements": { + "covered": 40, + "total": 42, + "percentage": 95.23 + }, + "branches": { + "covered": 8, + "total": 10, + "percentage": 80 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 40, + "total": 42, + "percentage": 95.23 + } + }, + "src/vs/platform/agentHost/node/chatContributions/markdownPlanRichLinks/markdownPlanRichLinksContribution.ts": { + "statements": { + "covered": 31, + "total": 46, + "percentage": 67.39 + }, + "branches": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 31, + "total": 46, + "percentage": 67.39 + } + }, + "src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts": { + "statements": { + "covered": 147, + "total": 165, + "percentage": 89.09 + }, + "branches": { + "covered": 35, + "total": 44, + "percentage": 79.54 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 147, + "total": 165, + "percentage": 89.09 + } + }, + "src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts": { + "statements": { + "covered": 137, + "total": 166, + "percentage": 82.53 + }, + "branches": { + "covered": 25, + "total": 35, + "percentage": 71.42 + }, + "functions": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "lines": { + "covered": 137, + "total": 166, + "percentage": 82.53 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts": { + "statements": { + "covered": 52, + "total": 52, + "percentage": 100 + }, + "branches": { + "covered": 11, + "total": 11, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 52, + "total": 52, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts": { + "statements": { + "covered": 206, + "total": 220, + "percentage": 93.63 + }, + "branches": { + "covered": 70, + "total": 76, + "percentage": 92.1 + }, + "functions": { + "covered": 13, + "total": 13, + "percentage": 100 + }, + "lines": { + "covered": 206, + "total": 220, + "percentage": 93.63 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts": { + "statements": { + "covered": 87, + "total": 98, + "percentage": 88.77 + }, + "branches": { + "covered": 21, + "total": 25, + "percentage": 84 + }, + "functions": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "lines": { + "covered": 87, + "total": 98, + "percentage": 88.77 + } + }, + "src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts": { + "statements": { + "covered": 52, + "total": 159, + "percentage": 32.7 + }, + "branches": { + "covered": 2, + "total": 6, + "percentage": 33.33 + }, + "functions": { + "covered": 2, + "total": 9, + "percentage": 22.22 + }, + "lines": { + "covered": 52, + "total": 159, + "percentage": 32.7 } }, - "src/vs/platform/agentHost/node/agentSideEffects.ts": { + "src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts": { "statements": { - "covered": 2104, - "total": 2450, - "percentage": 85.87 + "covered": 70, + "total": 83, + "percentage": 84.33 }, "branches": { - "covered": 419, - "total": 552, - "percentage": 75.9 + "covered": 18, + "total": 26, + "percentage": 69.23 }, "functions": { - "covered": 69, - "total": 77, - "percentage": 89.61 + "covered": 4, + "total": 4, + "percentage": 100 }, "lines": { - "covered": 2104, - "total": 2450, - "percentage": 85.87 + "covered": 70, + "total": 83, + "percentage": 84.33 } }, - "src/vs/platform/agentHost/node/appNodeModules.ts": { + "src/vs/platform/agentHost/node/chatContributions/turnAdmission/turnAdmissionContribution.ts": { "statements": { - "covered": 25, - "total": 27, - "percentage": 92.59 + "covered": 42, + "total": 48, + "percentage": 87.5 }, "branches": { "covered": 2, @@ -4427,35 +5307,57 @@ }, "functions": { "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 42, + "total": 48, + "percentage": 87.5 + } + }, + "src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts": { + "statements": { + "covered": 85, + "total": 97, + "percentage": 87.62 + }, + "branches": { + "covered": 17, + "total": 23, + "percentage": 73.91 + }, + "functions": { + "covered": 3, "total": 3, - "percentage": 66.66 + "percentage": 100 }, "lines": { - "covered": 25, - "total": 27, - "percentage": 92.59 + "covered": 85, + "total": 97, + "percentage": 87.62 } }, - "src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts": { + "src/vs/platform/agentHost/node/chatContributions/worktreeAnnouncement/worktreeAnnouncementContribution.ts": { "statements": { - "covered": 117, - "total": 204, - "percentage": 57.35 + "covered": 31, + "total": 31, + "percentage": 100 }, "branches": { - "covered": 1, - "total": 1, + "covered": 4, + "total": 4, "percentage": 100 }, "functions": { - "covered": 1, - "total": 13, - "percentage": 7.69 + "covered": 2, + "total": 2, + "percentage": 100 }, "lines": { - "covered": 117, - "total": 204, - "percentage": 57.35 + "covered": 31, + "total": 31, + "percentage": 100 } }, "src/vs/platform/agentHost/node/claude/anthropicBetas.ts": { @@ -4504,24 +5406,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2216, - "total": 2713, - "percentage": 81.68 + "covered": 2163, + "total": 2667, + "percentage": 81.1 }, "branches": { - "covered": 223, - "total": 334, - "percentage": 66.76 + "covered": 198, + "total": 301, + "percentage": 65.78 }, "functions": { - "covered": 99, - "total": 128, - "percentage": 77.34 + "covered": 97, + "total": 127, + "percentage": 76.37 }, "lines": { - "covered": 2216, - "total": 2713, - "percentage": 81.68 + "covered": 2163, + "total": 2667, + "percentage": 81.1 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { @@ -4548,31 +5450,31 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1270, + "covered": 1276, "total": 1631, - "percentage": 77.86 + "percentage": 78.23 }, "branches": { - "covered": 82, - "total": 137, - "percentage": 59.85 + "covered": 88, + "total": 141, + "percentage": 62.41 }, "functions": { - "covered": 42, + "covered": 43, "total": 69, - "percentage": 60.86 + "percentage": 62.31 }, "lines": { - "covered": 1270, + "covered": 1276, "total": 1631, - "percentage": 77.86 + "percentage": 78.23 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { "statements": { - "covered": 257, - "total": 301, - "percentage": 85.38 + "covered": 258, + "total": 302, + "percentage": 85.43 }, "branches": { "covered": 22, @@ -4585,9 +5487,9 @@ "percentage": 71.42 }, "lines": { - "covered": 257, - "total": 301, - "percentage": 85.38 + "covered": 258, + "total": 302, + "percentage": 85.43 } }, "src/vs/platform/agentHost/node/claude/claudeElicitation.ts": { @@ -4614,9 +5516,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeElicitationBridge.ts": { "statements": { - "covered": 36, - "total": 77, - "percentage": 46.75 + "covered": 35, + "total": 76, + "percentage": 46.05 }, "branches": { "covered": 0, @@ -4629,9 +5531,9 @@ "percentage": 0 }, "lines": { - "covered": 36, - "total": 77, - "percentage": 46.75 + "covered": 35, + "total": 76, + "percentage": 46.05 } }, "src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts": { @@ -4751,9 +5653,9 @@ "percentage": 83.83 }, "branches": { - "covered": 37, - "total": 48, - "percentage": 77.08 + "covered": 38, + "total": 49, + "percentage": 77.55 }, "functions": { "covered": 11, @@ -4883,9 +5785,9 @@ "percentage": 77.3 }, "branches": { - "covered": 61, - "total": 116, - "percentage": 52.58 + "covered": 59, + "total": 114, + "percentage": 51.75 }, "functions": { "covered": 18, @@ -4949,9 +5851,9 @@ "percentage": 77.53 }, "branches": { - "covered": 41, - "total": 65, - "percentage": 63.07 + "covered": 44, + "total": 68, + "percentage": 64.7 }, "functions": { "covered": 21, @@ -4988,24 +5890,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 225, + "covered": 205, "total": 261, - "percentage": 86.2 + "percentage": 78.54 }, "branches": { - "covered": 26, - "total": 41, - "percentage": 63.41 + "covered": 17, + "total": 35, + "percentage": 48.57 }, "functions": { - "covered": 9, + "covered": 8, "total": 9, - "percentage": 100 + "percentage": 88.88 }, "lines": { - "covered": 225, + "covered": 205, "total": 261, - "percentage": 86.2 + "percentage": 78.54 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -5037,9 +5939,9 @@ "percentage": 93.72 }, "branches": { - "covered": 42, - "total": 51, - "percentage": 82.35 + "covered": 40, + "total": 49, + "percentage": 81.63 }, "functions": { "covered": 17, @@ -5054,24 +5956,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentResolver.ts": { "statements": { - "covered": 236, + "covered": 212, "total": 439, - "percentage": 53.75 + "percentage": 48.29 }, "branches": { - "covered": 13, - "total": 20, - "percentage": 65 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "functions": { - "covered": 11, + "covered": 8, "total": 21, - "percentage": 52.38 + "percentage": 38.09 }, "lines": { - "covered": 236, + "covered": 212, "total": 439, - "percentage": 53.75 + "percentage": 48.29 } }, "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": { @@ -5142,14 +6044,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts": { "statements": { - "covered": 469, + "covered": 480, "total": 590, - "percentage": 79.49 + "percentage": 81.35 }, "branches": { - "covered": 73, - "total": 131, - "percentage": 55.72 + "covered": 77, + "total": 132, + "percentage": 58.33 }, "functions": { "covered": 15, @@ -5157,9 +6059,9 @@ "percentage": 88.23 }, "lines": { - "covered": 469, + "covered": 480, "total": 590, - "percentage": 79.49 + "percentage": 81.35 } }, "src/vs/platform/agentHost/node/claude/claudeTransportMode.ts": { @@ -5340,24 +6242,24 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { "statements": { - "covered": 169, + "covered": 171, "total": 237, - "percentage": 71.3 + "percentage": 72.15 }, "branches": { - "covered": 10, - "total": 14, - "percentage": 71.42 + "covered": 11, + "total": 15, + "percentage": 73.33 }, "functions": { - "covered": 9, + "covered": 10, "total": 14, - "percentage": 64.28 + "percentage": 71.42 }, "lines": { - "covered": 169, + "covered": 171, "total": 237, - "percentage": 71.3 + "percentage": 72.15 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { @@ -5367,9 +6269,9 @@ "percentage": 77.77 }, "branches": { - "covered": 47, - "total": 71, - "percentage": 66.19 + "covered": 48, + "total": 72, + "percentage": 66.66 }, "functions": { "covered": 13, @@ -5516,36 +6418,36 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 4475, - "total": 6780, - "percentage": 66 + "covered": 5557, + "total": 7847, + "percentage": 70.81 }, "branches": { - "covered": 517, - "total": 908, - "percentage": 56.93 + "covered": 847, + "total": 1343, + "percentage": 63.06 }, "functions": { - "covered": 186, - "total": 256, - "percentage": 72.65 + "covered": 232, + "total": 292, + "percentage": 79.45 }, "lines": { - "covered": 4475, - "total": 6780, - "percentage": 66 + "covered": 5557, + "total": 7847, + "percentage": 70.81 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { "statements": { - "covered": 414, + "covered": 417, "total": 481, - "percentage": 86.07 + "percentage": 86.69 }, "branches": { - "covered": 38, - "total": 59, - "percentage": 64.4 + "covered": 42, + "total": 62, + "percentage": 67.74 }, "functions": { "covered": 17, @@ -5553,21 +6455,21 @@ "percentage": 89.47 }, "lines": { - "covered": 414, + "covered": 417, "total": 481, - "percentage": 86.07 + "percentage": 86.69 } }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 302, + "covered": 308, "total": 372, - "percentage": 81.18 + "percentage": 82.79 }, "branches": { - "covered": 32, - "total": 63, - "percentage": 50.79 + "covered": 37, + "total": 70, + "percentage": 52.85 }, "functions": { "covered": 20, @@ -5575,31 +6477,31 @@ "percentage": 83.33 }, "lines": { - "covered": 302, + "covered": 308, "total": 372, - "percentage": 81.18 + "percentage": 82.79 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { "statements": { - "covered": 213, - "total": 286, - "percentage": 74.47 + "covered": 287, + "total": 335, + "percentage": 85.67 }, "branches": { - "covered": 17, - "total": 35, - "percentage": 48.57 + "covered": 33, + "total": 52, + "percentage": 63.46 }, "functions": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 9, + "total": 10, + "percentage": 90 }, "lines": { - "covered": 213, - "total": 286, - "percentage": 74.47 + "covered": 287, + "total": 335, + "percentage": 85.67 } }, "src/vs/platform/agentHost/node/codex/codexDelegation.ts": { @@ -5670,24 +6572,24 @@ }, "src/vs/platform/agentHost/node/codex/codexForkPlan.ts": { "statements": { - "covered": 54, + "covered": 80, "total": 86, - "percentage": 62.79 + "percentage": 93.02 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 3, + "total": 7, + "percentage": 42.85 }, "functions": { - "covered": 0, + "covered": 2, "total": 2, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 54, + "covered": 80, "total": 86, - "percentage": 62.79 + "percentage": 93.02 } }, "src/vs/platform/agentHost/node/codex/codexGuardianReview.ts": { @@ -5719,9 +6621,9 @@ "percentage": 73.84 }, "branches": { - "covered": 5, - "total": 18, - "percentage": 27.77 + "covered": 7, + "total": 19, + "percentage": 36.84 }, "functions": { "covered": 3, @@ -5736,24 +6638,24 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 657, + "covered": 667, "total": 1281, - "percentage": 51.28 + "percentage": 52.06 }, "branches": { - "covered": 61, - "total": 116, - "percentage": 52.58 + "covered": 70, + "total": 126, + "percentage": 55.55 }, "functions": { - "covered": 19, + "covered": 20, "total": 43, - "percentage": 44.18 + "percentage": 46.51 }, "lines": { - "covered": 657, + "covered": 667, "total": 1281, - "percentage": 51.28 + "percentage": 52.06 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -5763,9 +6665,9 @@ "percentage": 83.44 }, "branches": { - "covered": 44, - "total": 61, - "percentage": 72.13 + "covered": 50, + "total": 67, + "percentage": 74.62 }, "functions": { "covered": 20, @@ -5778,16 +6680,38 @@ "percentage": 83.44 } }, + "src/vs/platform/agentHost/node/codex/codexProfileImage.ts": { + "statements": { + "covered": 87, + "total": 363, + "percentage": 23.96 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 20, + "percentage": 0 + }, + "lines": { + "covered": 87, + "total": 363, + "percentage": 23.96 + } + }, "src/vs/platform/agentHost/node/codex/codexPromptResolver.ts": { "statements": { - "covered": 79, - "total": 194, - "percentage": 40.72 + "covered": 121, + "total": 206, + "percentage": 58.73 }, "branches": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 11, + "total": 17, + "percentage": 64.7 }, "functions": { "covered": 1, @@ -5795,9 +6719,9 @@ "percentage": 25 }, "lines": { - "covered": 79, - "total": 194, - "percentage": 40.72 + "covered": 121, + "total": 206, + "percentage": 58.73 } }, "src/vs/platform/agentHost/node/codex/codexProviderConfiguration.ts": { @@ -5824,14 +6748,14 @@ }, "src/vs/platform/agentHost/node/codex/codexProxyService.ts": { "statements": { - "covered": 390, + "covered": 393, "total": 484, - "percentage": 80.57 + "percentage": 81.19 }, "branches": { - "covered": 33, - "total": 79, - "percentage": 41.77 + "covered": 36, + "total": 82, + "percentage": 43.9 }, "functions": { "covered": 11, @@ -5839,43 +6763,43 @@ "percentage": 78.57 }, "lines": { - "covered": 390, + "covered": 393, "total": 484, - "percentage": 80.57 + "percentage": 81.19 } }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 79, - "total": 378, - "percentage": 20.89 + "covered": 178, + "total": 380, + "percentage": 46.84 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 6, + "total": 26, + "percentage": 23.07 }, "functions": { - "covered": 0, - "total": 12, - "percentage": 0 + "covered": 4, + "total": 13, + "percentage": 30.76 }, "lines": { - "covered": 79, - "total": 378, - "percentage": 20.89 + "covered": 178, + "total": 380, + "percentage": 46.84 } }, "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { "statements": { - "covered": 117, + "covered": 125, "total": 159, - "percentage": 73.58 + "percentage": 78.61 }, "branches": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 30, + "total": 36, + "percentage": 83.33 }, "functions": { "covered": 5, @@ -5883,9 +6807,9 @@ "percentage": 100 }, "lines": { - "covered": 117, + "covered": 125, "total": 159, - "percentage": 73.58 + "percentage": 78.61 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -5895,9 +6819,9 @@ "percentage": 87.87 }, "branches": { - "covered": 22, - "total": 44, - "percentage": 50 + "covered": 25, + "total": 46, + "percentage": 54.34 }, "functions": { "covered": 11, @@ -5912,14 +6836,14 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 230, + "covered": 233, "total": 256, - "percentage": 89.84 + "percentage": 91.01 }, "branches": { - "covered": 20, - "total": 37, - "percentage": 54.05 + "covered": 32, + "total": 47, + "percentage": 68.08 }, "functions": { "covered": 7, @@ -5927,21 +6851,21 @@ "percentage": 100 }, "lines": { - "covered": 230, + "covered": 233, "total": 256, - "percentage": 89.84 + "percentage": 91.01 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { "statements": { - "covered": 36, + "covered": 38, "total": 42, - "percentage": 85.71 + "percentage": 90.47 }, "branches": { - "covered": 2, - "total": 6, - "percentage": 33.33 + "covered": 7, + "total": 9, + "percentage": 77.77 }, "functions": { "covered": 2, @@ -5949,31 +6873,31 @@ "percentage": 100 }, "lines": { - "covered": 36, + "covered": 38, "total": 42, - "percentage": 85.71 + "percentage": 90.47 } }, "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { "statements": { - "covered": 42, + "covered": 67, "total": 189, - "percentage": 22.22 + "percentage": 35.44 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 1, + "total": 9, + "percentage": 11.11 }, "functions": { - "covered": 0, + "covered": 1, "total": 8, - "percentage": 0 + "percentage": 12.5 }, "lines": { - "covered": 42, + "covered": 67, "total": 189, - "percentage": 22.22 + "percentage": 35.44 } }, "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { @@ -6000,24 +6924,24 @@ }, "src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts": { "statements": { - "covered": 32, + "covered": 78, "total": 88, - "percentage": 36.36 + "percentage": 88.63 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 12, + "total": 18, + "percentage": 66.66 }, "functions": { - "covered": 0, + "covered": 4, "total": 4, - "percentage": 0 + "percentage": 100 }, "lines": { - "covered": 32, + "covered": 78, "total": 88, - "percentage": 36.36 + "percentage": 88.63 } }, "src/vs/platform/agentHost/node/codexCompactCommand.ts": { @@ -6044,14 +6968,14 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 567, + "covered": 563, "total": 704, - "percentage": 80.53 + "percentage": 79.97 }, "branches": { - "covered": 39, - "total": 82, - "percentage": 47.56 + "covered": 29, + "total": 76, + "percentage": 38.15 }, "functions": { "covered": 18, @@ -6059,9 +6983,9 @@ "percentage": 85.71 }, "lines": { - "covered": 567, + "covered": 563, "total": 704, - "percentage": 80.53 + "percentage": 79.97 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { @@ -6154,68 +7078,68 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4880, - "total": 6477, - "percentage": 75.34 + "covered": 5083, + "total": 6832, + "percentage": 74.39 }, "branches": { - "covered": 805, - "total": 1225, - "percentage": 65.71 + "covered": 855, + "total": 1270, + "percentage": 67.32 }, "functions": { - "covered": 275, - "total": 335, - "percentage": 82.08 + "covered": 280, + "total": 349, + "percentage": 80.22 }, "lines": { - "covered": 4880, - "total": 6477, - "percentage": 75.34 + "covered": 5083, + "total": 6832, + "percentage": 74.39 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4460, - "total": 6062, - "percentage": 73.57 + "covered": 4616, + "total": 6392, + "percentage": 72.21 }, "branches": { - "covered": 822, - "total": 1183, - "percentage": 69.48 + "covered": 873, + "total": 1274, + "percentage": 68.52 }, "functions": { - "covered": 197, - "total": 244, - "percentage": 80.73 + "covered": 201, + "total": 252, + "percentage": 79.76 }, "lines": { - "covered": 4460, - "total": 6062, - "percentage": 73.57 + "covered": 4616, + "total": 6392, + "percentage": 72.21 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts": { "statements": { - "covered": 38, + "covered": 45, "total": 45, - "percentage": 84.44 + "percentage": 100 }, "branches": { - "covered": 6, - "total": 6, - "percentage": 100 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "functions": { - "covered": 3, + "covered": 5, "total": 5, - "percentage": 60 + "percentage": 100 }, "lines": { - "covered": 38, + "covered": 45, "total": 45, - "percentage": 84.44 + "percentage": 100 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -6242,14 +7166,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts": { "statements": { - "covered": 32, - "total": 32, + "covered": 40, + "total": 40, "percentage": 100 }, "branches": { - "covered": 7, - "total": 7, - "percentage": 100 + "covered": 8, + "total": 9, + "percentage": 88.88 }, "functions": { "covered": 1, @@ -6257,8 +7181,8 @@ "percentage": 100 }, "lines": { - "covered": 32, - "total": 32, + "covered": 40, + "total": 40, "percentage": 100 } }, @@ -6286,14 +7210,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 250, - "total": 256, - "percentage": 97.65 + "covered": 243, + "total": 247, + "percentage": 98.38 }, "branches": { "covered": 5, - "total": 10, - "percentage": 50 + "total": 9, + "percentage": 55.55 }, "functions": { "covered": 2, @@ -6301,9 +7225,9 @@ "percentage": 100 }, "lines": { - "covered": 250, - "total": 256, - "percentage": 97.65 + "covered": 243, + "total": 247, + "percentage": 98.38 } }, "src/vs/platform/agentHost/node/copilot/copilotGitProject.ts": { @@ -6352,24 +7276,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 375, - "total": 520, - "percentage": 72.11 + "covered": 385, + "total": 530, + "percentage": 72.64 }, "branches": { - "covered": 42, - "total": 78, - "percentage": 53.84 + "covered": 44, + "total": 81, + "percentage": 54.32 }, "functions": { - "covered": 19, - "total": 25, - "percentage": 76 + "covered": 20, + "total": 26, + "percentage": 76.92 }, "lines": { - "covered": 375, - "total": 520, - "percentage": 72.11 + "covered": 385, + "total": 530, + "percentage": 72.64 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -6380,8 +7304,8 @@ }, "branches": { "covered": 3, - "total": 16, - "percentage": 18.75 + "total": 15, + "percentage": 20 }, "functions": { "covered": 3, @@ -6394,48 +7318,70 @@ "percentage": 90.62 } }, + "src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts": { + "statements": { + "covered": 28, + "total": 31, + "percentage": 90.32 + }, + "branches": { + "covered": 2, + "total": 5, + "percentage": 40 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 28, + "total": 31, + "percentage": 90.32 + } + }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 701, - "total": 906, - "percentage": 77.37 + "covered": 810, + "total": 1034, + "percentage": 78.33 }, "branches": { - "covered": 91, - "total": 136, - "percentage": 66.91 + "covered": 100, + "total": 154, + "percentage": 64.93 }, "functions": { - "covered": 35, - "total": 47, - "percentage": 74.46 + "covered": 42, + "total": 52, + "percentage": 80.76 }, "lines": { - "covered": 701, - "total": 906, - "percentage": 77.37 + "covered": 810, + "total": 1034, + "percentage": 78.33 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { "statements": { - "covered": 324, - "total": 338, - "percentage": 95.85 + "covered": 329, + "total": 343, + "percentage": 95.91 }, "branches": { - "covered": 66, - "total": 66, + "covered": 67, + "total": 67, "percentage": 100 }, "functions": { - "covered": 56, - "total": 58, - "percentage": 96.55 + "covered": 57, + "total": 59, + "percentage": 96.61 }, "lines": { - "covered": 324, - "total": 338, - "percentage": 95.85 + "covered": 329, + "total": 343, + "percentage": 95.91 } }, "src/vs/platform/agentHost/node/copilot/copilotShellTools.ts": { @@ -6463,8 +7409,8 @@ "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { "statements": { "covered": 100, - "total": 270, - "percentage": 37.03 + "total": 271, + "percentage": 36.9 }, "branches": { "covered": 2, @@ -6478,8 +7424,8 @@ }, "lines": { "covered": 100, - "total": 270, - "percentage": 37.03 + "total": 271, + "percentage": 36.9 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts": { @@ -6550,36 +7496,36 @@ }, "src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts": { "statements": { - "covered": 977, - "total": 1214, - "percentage": 80.47 + "covered": 1008, + "total": 1258, + "percentage": 80.12 }, "branches": { - "covered": 165, - "total": 278, - "percentage": 59.35 + "covered": 168, + "total": 283, + "percentage": 59.36 }, "functions": { - "covered": 27, - "total": 35, - "percentage": 77.14 + "covered": 29, + "total": 37, + "percentage": 78.37 }, "lines": { - "covered": 977, - "total": 1214, - "percentage": 80.47 + "covered": 1008, + "total": 1258, + "percentage": 80.12 } }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 672, - "total": 956, - "percentage": 70.29 + "covered": 694, + "total": 1021, + "percentage": 67.97 }, "branches": { - "covered": 85, - "total": 166, - "percentage": 51.2 + "covered": 93, + "total": 186, + "percentage": 50 }, "functions": { "covered": 21, @@ -6587,30 +7533,52 @@ "percentage": 95.45 }, "lines": { - "covered": 672, - "total": 956, - "percentage": 70.29 + "covered": 694, + "total": 1021, + "percentage": 67.97 + } + }, + "src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts": { + "statements": { + "covered": 60, + "total": 69, + "percentage": 86.95 + }, + "branches": { + "covered": 8, + "total": 13, + "percentage": 61.53 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 60, + "total": 69, + "percentage": 86.95 } }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { "statements": { - "covered": 14, - "total": 14, + "covered": 19, + "total": 19, "percentage": 100 }, "branches": { - "covered": 2, - "total": 2, + "covered": 3, + "total": 3, "percentage": 100 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 14, - "total": 14, + "covered": 19, + "total": 19, "percentage": 100 } }, @@ -6682,24 +7650,24 @@ }, "src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts": { "statements": { - "covered": 206, - "total": 229, - "percentage": 89.95 + "covered": 210, + "total": 233, + "percentage": 90.12 }, "branches": { - "covered": 13, - "total": 23, - "percentage": 56.52 + "covered": 14, + "total": 24, + "percentage": 58.33 }, "functions": { - "covered": 7, - "total": 7, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 206, - "total": 229, - "percentage": 89.95 + "covered": 210, + "total": 233, + "percentage": 90.12 } }, "src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts": { @@ -6726,24 +7694,24 @@ }, "src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts": { "statements": { - "covered": 119, - "total": 138, - "percentage": 86.23 + "covered": 144, + "total": 163, + "percentage": 88.34 }, "branches": { - "covered": 8, - "total": 19, - "percentage": 42.1 + "covered": 9, + "total": 21, + "percentage": 42.85 }, "functions": { - "covered": 6, - "total": 7, - "percentage": 85.71 + "covered": 7, + "total": 8, + "percentage": 87.5 }, "lines": { - "covered": 119, - "total": 138, - "percentage": 86.23 + "covered": 144, + "total": 163, + "percentage": 88.34 } }, "src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts": { @@ -6770,14 +7738,14 @@ }, "src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts": { "statements": { - "covered": 1151, + "covered": 1144, "total": 1279, - "percentage": 89.99 + "percentage": 89.44 }, "branches": { - "covered": 229, - "total": 281, - "percentage": 81.49 + "covered": 230, + "total": 284, + "percentage": 80.98 }, "functions": { "covered": 38, @@ -6785,16 +7753,16 @@ "percentage": 97.43 }, "lines": { - "covered": 1151, + "covered": 1144, "total": 1279, - "percentage": 89.99 + "percentage": 89.44 } }, "src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts": { "statements": { - "covered": 26, - "total": 45, - "percentage": 57.77 + "covered": 23, + "total": 42, + "percentage": 54.76 }, "branches": { "covered": 0, @@ -6807,9 +7775,9 @@ "percentage": 0 }, "lines": { - "covered": 26, - "total": 45, - "percentage": 57.77 + "covered": 23, + "total": 42, + "percentage": 54.76 } }, "src/vs/platform/agentHost/node/diffComputeService.ts": { @@ -6841,9 +7809,9 @@ "percentage": 54.02 }, "branches": { - "covered": 14, - "total": 19, - "percentage": 73.68 + "covered": 15, + "total": 20, + "percentage": 75 }, "functions": { "covered": 4, @@ -6902,14 +7870,14 @@ }, "src/vs/platform/agentHost/node/localCommands/localChatCommand.ts": { "statements": { - "covered": 244, - "total": 255, - "percentage": 95.68 + "covered": 239, + "total": 250, + "percentage": 95.6 }, "branches": { - "covered": 28, + "covered": 27, "total": 36, - "percentage": 77.77 + "percentage": 75 }, "functions": { "covered": 13, @@ -6917,9 +7885,9 @@ "percentage": 100 }, "lines": { - "covered": 244, - "total": 255, - "percentage": 95.68 + "covered": 239, + "total": 250, + "percentage": 95.6 } }, "src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts": { @@ -6973,9 +7941,9 @@ "percentage": 94.89 }, "branches": { - "covered": 31, - "total": 38, - "percentage": 81.57 + "covered": 30, + "total": 37, + "percentage": 81.08 }, "functions": { "covered": 8, @@ -7034,24 +8002,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1704, - "total": 1947, - "percentage": 87.51 + "covered": 1803, + "total": 2128, + "percentage": 84.72 }, "branches": { - "covered": 350, - "total": 439, - "percentage": 79.72 + "covered": 375, + "total": 488, + "percentage": 76.84 }, "functions": { - "covered": 85, - "total": 95, - "percentage": 89.47 + "covered": 88, + "total": 96, + "percentage": 91.66 }, "lines": { - "covered": 1704, - "total": 1947, - "percentage": 87.51 + "covered": 1803, + "total": 2128, + "percentage": 84.72 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -7076,82 +8044,60 @@ "percentage": 50 } }, - "src/vs/platform/agentHost/node/sessionCoordination.ts": { - "statements": { - "covered": 78, - "total": 159, - "percentage": 49.05 - }, - "branches": { - "covered": 10, - "total": 17, - "percentage": 58.82 - }, - "functions": { - "covered": 4, - "total": 6, - "percentage": 66.66 - }, - "lines": { - "covered": 78, - "total": 159, - "percentage": 49.05 - } - }, "src/vs/platform/agentHost/node/sessionDataService.ts": { "statements": { - "covered": 157, - "total": 198, - "percentage": 79.29 + "covered": 166, + "total": 207, + "percentage": 80.19 }, "branches": { - "covered": 22, - "total": 25, - "percentage": 88 + "covered": 27, + "total": 31, + "percentage": 87.09 }, "functions": { - "covered": 12, - "total": 14, - "percentage": 85.71 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "lines": { - "covered": 157, - "total": 198, - "percentage": 79.29 + "covered": 166, + "total": 207, + "percentage": 80.19 } }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 772, - "total": 909, - "percentage": 84.92 + "covered": 807, + "total": 946, + "percentage": 85.3 }, "branches": { - "covered": 121, - "total": 150, - "percentage": 80.66 + "covered": 130, + "total": 160, + "percentage": 81.25 }, "functions": { - "covered": 41, - "total": 55, - "percentage": 74.54 + "covered": 43, + "total": 57, + "percentage": 75.43 }, "lines": { - "covered": 772, - "total": 909, - "percentage": 84.92 + "covered": 807, + "total": 946, + "percentage": 85.3 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { "statements": { - "covered": 277, + "covered": 271, "total": 507, - "percentage": 54.63 + "percentage": 53.45 }, "branches": { - "covered": 24, - "total": 43, - "percentage": 55.81 + "covered": 21, + "total": 41, + "percentage": 51.21 }, "functions": { "covered": 4, @@ -7159,38 +8105,38 @@ "percentage": 66.66 }, "lines": { - "covered": 277, + "covered": 271, "total": 507, - "percentage": 54.63 + "percentage": 53.45 } }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 549, - "total": 707, - "percentage": 77.65 + "covered": 573, + "total": 760, + "percentage": 75.39 }, "branches": { - "covered": 94, - "total": 137, - "percentage": 68.61 + "covered": 85, + "total": 132, + "percentage": 64.39 }, "functions": { - "covered": 24, - "total": 29, - "percentage": 82.75 + "covered": 25, + "total": 30, + "percentage": 83.33 }, "lines": { - "covered": 549, - "total": 707, - "percentage": 77.65 + "covered": 573, + "total": 760, + "percentage": 75.39 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { "statements": { - "covered": 129, - "total": 169, - "percentage": 76.33 + "covered": 134, + "total": 174, + "percentage": 77.01 }, "branches": { "covered": 7, @@ -7203,9 +8149,9 @@ "percentage": 85.71 }, "lines": { - "covered": 129, - "total": 169, - "percentage": 76.33 + "covered": 134, + "total": 174, + "percentage": 77.01 } }, "src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts": { @@ -7237,9 +8183,9 @@ "percentage": 89.82 }, "branches": { - "covered": 60, - "total": 98, - "percentage": 61.22 + "covered": 61, + "total": 99, + "percentage": 61.61 }, "functions": { "covered": 26, @@ -7281,9 +8227,9 @@ "percentage": 48 }, "branches": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 4, + "total": 4, + "percentage": 100 }, "functions": { "covered": 2, @@ -7298,24 +8244,24 @@ }, "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { "statements": { - "covered": 192, - "total": 203, - "percentage": 94.58 + "covered": 247, + "total": 258, + "percentage": 95.73 }, "branches": { - "covered": 23, - "total": 31, - "percentage": 74.19 + "covered": 44, + "total": 53, + "percentage": 83.01 }, "functions": { - "covered": 11, - "total": 11, + "covered": 12, + "total": 12, "percentage": 100 }, "lines": { - "covered": 192, - "total": 203, - "percentage": 94.58 + "covered": 247, + "total": 258, + "percentage": 95.73 } }, "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { @@ -7342,24 +8288,24 @@ }, "src/vs/platform/agentHost/node/shared/artifactServerTools.ts": { "statements": { - "covered": 108, - "total": 175, - "percentage": 61.71 + "covered": 198, + "total": 209, + "percentage": 94.73 }, "branches": { - "covered": 2, - "total": 2, - "percentage": 100 + "covered": 38, + "total": 51, + "percentage": 74.5 }, "functions": { - "covered": 2, - "total": 8, - "percentage": 25 + "covered": 9, + "total": 9, + "percentage": 100 }, "lines": { - "covered": 108, - "total": 175, - "percentage": 61.71 + "covered": 198, + "total": 209, + "percentage": 94.73 } }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { @@ -7369,9 +8315,9 @@ "percentage": 87.31 }, "branches": { - "covered": 52, - "total": 91, - "percentage": 57.14 + "covered": 59, + "total": 97, + "percentage": 60.82 }, "functions": { "covered": 25, @@ -7391,9 +8337,9 @@ "percentage": 88.64 }, "branches": { - "covered": 53, - "total": 59, - "percentage": 89.83 + "covered": 51, + "total": 57, + "percentage": 89.47 }, "functions": { "covered": 8, @@ -7474,14 +8420,14 @@ }, "src/vs/platform/agentHost/node/shared/editSurvivalTracker.ts": { "statements": { - "covered": 209, + "covered": 205, "total": 236, - "percentage": 88.55 + "percentage": 86.86 }, "branches": { - "covered": 21, - "total": 28, - "percentage": 75 + "covered": 18, + "total": 26, + "percentage": 69.23 }, "functions": { "covered": 6, @@ -7489,21 +8435,21 @@ "percentage": 75 }, "lines": { - "covered": 209, + "covered": 205, "total": 236, - "percentage": 88.55 + "percentage": 86.86 } }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 239, + "covered": 237, "total": 253, - "percentage": 94.46 + "percentage": 93.67 }, "branches": { - "covered": 35, - "total": 41, - "percentage": 85.36 + "covered": 32, + "total": 39, + "percentage": 82.05 }, "functions": { "covered": 7, @@ -7511,9 +8457,9 @@ "percentage": 100 }, "lines": { - "covered": 239, + "covered": 237, "total": 253, - "percentage": 94.46 + "percentage": 93.67 } }, "src/vs/platform/agentHost/node/shared/folderPickerDecision.ts": { @@ -7562,14 +8508,14 @@ }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { "statements": { - "covered": 286, + "covered": 287, "total": 332, - "percentage": 86.14 + "percentage": 86.44 }, "branches": { - "covered": 22, - "total": 32, - "percentage": 68.75 + "covered": 23, + "total": 34, + "percentage": 67.64 }, "functions": { "covered": 10, @@ -7577,31 +8523,31 @@ "percentage": 76.92 }, "lines": { - "covered": 286, + "covered": 287, "total": 332, - "percentage": 86.14 + "percentage": 86.44 } }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 487, + "covered": 506, "total": 586, - "percentage": 83.1 + "percentage": 86.34 }, "branches": { - "covered": 105, - "total": 122, - "percentage": 86.06 + "covered": 110, + "total": 129, + "percentage": 85.27 }, "functions": { - "covered": 26, + "covered": 28, "total": 32, - "percentage": 81.25 + "percentage": 87.5 }, "lines": { - "covered": 487, + "covered": 506, "total": 586, - "percentage": 83.1 + "percentage": 86.34 } }, "src/vs/platform/agentHost/node/shared/mcpServerWorkingDirectory.ts": { @@ -7626,6 +8572,28 @@ "percentage": 90 } }, + "src/vs/platform/agentHost/node/shared/modelRefreshRetry.ts": { + "statements": { + "covered": 13, + "total": 16, + "percentage": 81.25 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 13, + "total": 16, + "percentage": 81.25 + } + }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { "statements": { "covered": 57, @@ -7672,14 +8640,14 @@ }, "src/vs/platform/agentHost/node/shared/serverToolGroups.ts": { "statements": { - "covered": 77, - "total": 79, - "percentage": 97.46 + "covered": 85, + "total": 91, + "percentage": 93.4 }, "branches": { - "covered": 9, - "total": 10, - "percentage": 90 + "covered": 13, + "total": 16, + "percentage": 81.25 }, "functions": { "covered": 3, @@ -7687,21 +8655,21 @@ "percentage": 100 }, "lines": { - "covered": 77, - "total": 79, - "percentage": 97.46 + "covered": 85, + "total": 91, + "percentage": 93.4 } }, "src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts": { "statements": { - "covered": 162, + "covered": 166, "total": 200, - "percentage": 81 + "percentage": 83 }, "branches": { - "covered": 27, - "total": 33, - "percentage": 81.81 + "covered": 33, + "total": 38, + "percentage": 86.84 }, "functions": { "covered": 10, @@ -7709,31 +8677,31 @@ "percentage": 100 }, "lines": { - "covered": 162, + "covered": 166, "total": 200, - "percentage": 81 + "percentage": 83 } }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1112, - "total": 1400, - "percentage": 79.42 + "covered": 1214, + "total": 1439, + "percentage": 84.36 }, "branches": { - "covered": 160, - "total": 276, - "percentage": 57.97 + "covered": 208, + "total": 347, + "percentage": 59.94 }, "functions": { - "covered": 49, - "total": 60, - "percentage": 81.66 + "covered": 56, + "total": 62, + "percentage": 90.32 }, "lines": { - "covered": 1112, - "total": 1400, - "percentage": 79.42 + "covered": 1214, + "total": 1439, + "percentage": 84.36 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -7758,26 +8726,48 @@ "percentage": 61.82 } }, + "src/vs/platform/agentHost/node/shared/toolCallContributor.ts": { + "statements": { + "covered": 48, + "total": 49, + "percentage": 97.95 + }, + "branches": { + "covered": 10, + "total": 11, + "percentage": 90.9 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 48, + "total": 49, + "percentage": 97.95 + } + }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 908, - "total": 1234, - "percentage": 73.58 + "covered": 1191, + "total": 1529, + "percentage": 77.89 }, "branches": { - "covered": 96, - "total": 163, - "percentage": 58.89 + "covered": 160, + "total": 238, + "percentage": 67.22 }, "functions": { - "covered": 39, - "total": 52, - "percentage": 75 + "covered": 49, + "total": 87, + "percentage": 56.32 }, "lines": { - "covered": 908, - "total": 1234, - "percentage": 73.58 + "covered": 1191, + "total": 1529, + "percentage": 77.89 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { @@ -7804,24 +8794,24 @@ }, "src/vs/platform/agentHost/node/workspacelessScratchDir.ts": { "statements": { - "covered": 20, - "total": 25, - "percentage": 80 + "covered": 16, + "total": 21, + "percentage": 76.19 }, "branches": { - "covered": 1, - "total": 1, + "covered": 0, + "total": 0, "percentage": 100 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 0, + "total": 1, + "percentage": 0 }, "lines": { - "covered": 20, - "total": 25, - "percentage": 80 + "covered": 16, + "total": 21, + "percentage": 76.19 } } } diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index c6cf404966b273..3c89971d46df9d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -382,6 +382,8 @@ export interface IAgentHostE2EProviderConfig { * notifications there. Recording and other platforms keep full coverage. */ readonly shellToolReplayUnstableOnLinux?: boolean; + /** Provider intermittently completes successful shell calls without exposing result text. */ + readonly shellToolResultTextUnreliable?: boolean; /** * When set, the subagent-reopen ("replay path") test is skipped on Windows for * this provider, which rebuilds the reopened transcript from the bundled SDK's diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 82befd2669a3ab..7d5f4d1703e714 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -52,4 +52,5 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { supportsSideChats: true, supportsSideChatsE2E: true, shellToolReplayUnstableOnLinux: true, + shellToolResultTextUnreliable: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index 424b794d0c5b6c..3aefe4bf299509 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -25,6 +25,8 @@ import { defineTurnLifecycleTests } from './turnLifecycleSuite.js'; import { defineWorkspaceTests } from './workspaceSuite.js'; import { defineCopilotCoverageTests } from './copilotCoverageSuite.js'; import { defineManagementExtensionTests } from './managementExtensionsSuite.js'; +import { defineAutomationsTests } from './automationsSuite.js'; +import { defineDetachedWorktreeTests } from './detachedWorktreeSuite.js'; import type { AgentHostE2ETier, IAgentHostE2ETestContext } from './e2eTestContext.js'; const isLinux = process.platform === 'linux'; @@ -146,12 +148,14 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption // Suites that contain only conformance-tier scenarios. if (options.tier === 'conformance') { + defineAutomationsTests(context); defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); defineClientHostedFilesystemTests(context); defineAnnotationsTests(context); defineProtocolContractTests(context); + defineDetachedWorktreeTests(context); } // Suites that contain only parity-tier scenarios. diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts new file mode 100644 index 00000000000000..f5e065f088b381 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.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 assert from 'assert'; +import { equals } from '../../../../../../base/common/objects.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY } from '../../../../common/automationMigration.js'; +import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { AutomationOperation, type AutomationDefinition, type AutomationEntry } from '../../../../common/state/protocol/state.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ActionType, type AutomationRemovedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; +import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; +import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +/** The migration gate's message, checked before the enablement gate's. */ +const MIGRATION_REQUIRED_MESSAGE = 'Automation migration must complete before automations can be accessed or run.'; +const AUTOMATIONS_DISABLED_MESSAGE = 'Automations are disabled.'; +/** Mirrors the host's advertised `runHistoryLimit`. */ +const RUN_HISTORY_LIMIT = 50; + +const UNGATED_OPERATIONS = [AutomationOperation.Update, AutomationOperation.Remove]; +const GATED_OPERATIONS = [AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run]; + +/** + * The host-owned automation catalogue, exercised entirely over AHP. + * + * Everything here stays on the host side of the model boundary: an automation + * is only a durable definition until something starts a run, and no test here + * runs one. Every definition is therefore manual-only (`triggers: []`), which + * also keeps the host's cron scheduler — which reads the real clock and has no + * injectable seam — out of the suite. + */ +export function defineAutomationsTests(context: IAgentHostE2ETestContext): void { + const { config } = context; + let clientSeq = 1; + + function nextClientSeq(): number { + return clientSeq++; + } + + async function initializeRoot(prefix: string): Promise { + return context.client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `${prefix}-${config.provider}`, + }, 30_000); + } + + async function rootConfigValues(): Promise>> { + const result = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + return (result.snapshot!.state as RootState).config?.values ?? {}; + } + + /** + * Replaces one root-config value, skipping the dispatch when the host already + * holds it. An unchanged patch is a deliberate no-op in the state manager: it + * emits no action at all, so waiting for the echo would hang. Both automation + * gates are durable for the life of the shared host, so tests re-open them + * defensively and hit that no-op constantly. + */ + async function setRootConfigValue(key: string, value: unknown): Promise { + if (equals((await rootConfigValues())[key], value)) { + return; + } + const seq = nextClientSeq(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: seq, + action: { type: ActionType.RootConfigChanged, config: { [key]: value } }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === seq, + ); + } + + function setAutomationsEnabled(enabled: boolean): Promise { + return setRootConfigValue(AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, enabled); + } + + /** + * Completes automation migration. The host requires this as an isolated + * root-config patch and refuses it while automations are disabled, so it is + * always dispatched on its own and after {@link setAutomationsEnabled}. + */ + function completeAutomationMigration(): Promise { + return setRootConfigValue(AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, { version: 1, status: 'complete', resources: [] }); + } + + /** Opens both gates. Idempotent, so each test can stand on its own. */ + async function openAutomationGates(): Promise { + await setAutomationsEnabled(true); + await completeAutomationMigration(); + } + + async function subscribeCatalog(): Promise { + const result = await context.client.call('subscribe', { channel: AUTOMATION_CATALOG_URI }); + return result.snapshot!.state as AutomationState; + } + + function automationResource(prefix: string): string { + return `ahp-automation:/${prefix}-${generateUuid()}`; + } + + function buildDefinition(title: string): AutomationDefinition { + return { + title, + // An automation message must declare an automation origin, and an empty + // session template is enough for a definition that is never run. + message: { text: 'Reply exactly "ran".', origin: { kind: MessageKind.Automation } }, + session: {}, + enabled: false, + triggers: [], + }; + } + + function automationSetFor(resource: string, accept: (automation: AutomationEntry) => boolean): (notification: AhpNotification) => boolean { + return notification => { + if (!isActionNotification(notification, ActionType.AutomationSet) || getActionEnvelope(notification).channel !== AUTOMATION_CATALOG_URI) { + return false; + } + const { automation } = getActionEnvelope(notification).action as AutomationSetAction; + return automation.resource === resource && accept(automation); + }; + } + + /** + * Waits for the authoritative `automation/set` the host publishes after it has + * persisted a mutation. The client's own `automation/createRequested` is never + * echoed back, so this is the only accept signal; a failed mutation instead + * comes back as a rejected envelope carrying the request's action type. + */ + async function waitForAutomationSet(resource: string, accept: (automation: AutomationEntry) => boolean = () => true): Promise { + const notification = await context.client.waitForNotification(automationSetFor(resource, accept)); + return (getActionEnvelope(notification).action as AutomationSetAction).automation; + } + + async function createAutomation(resource: string, definition: AutomationDefinition): Promise { + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationCreateRequested, resource, definition }, + }); + return waitForAutomationSet(resource); + } + + /** The message a failed request reported, or a marker when it unexpectedly succeeded. */ + async function rejectionMessage(request: Promise): Promise { + try { + await request; + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + function listTriggerDefinitions(): Promise { + return context.client.call('listAutomationTriggerDefinitions', { channel: ROOT_STATE_URI }); + } + + function entryFor(catalog: AutomationState, resource: string): AutomationEntry | undefined { + return catalog.entries.find(entry => entry.resource === resource); + } + + // Registered first, before anything in this file opens a gate or writes an + // entry: both assertions describe a host that has never had an automation. + conformanceTest(context, 'a fresh agent host advertises the automation catalogue and its capabilities', async function () { + const initialized = await initializeRoot('automations-capabilities'); + + const catalog = await subscribeCatalog(); + + // The catalogue and its commands are advertised before either gate opens: + // a client can always render the (empty) catalogue and author into it. + assert.deepStrictEqual({ + automations: initialized.automations, + entries: catalog.entries, + }, { + automations: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: RUN_HISTORY_LIMIT }, + entries: [], + }); + }); + + // Migration completion is durable for the life of the host — including across + // restarts, since it is stored alongside the catalogue — so this is the only + // test that can observe the pre-migration gate. It must stay registered ahead + // of every test that calls `openAutomationGates`. + conformanceTest(context, 'automation commands are rejected until automations are enabled and migration completes', async function () { + await initializeRoot('automations-gates'); + + const beforeAnyGate = await rejectionMessage(listTriggerDefinitions()); + await setAutomationsEnabled(true); + const afterEnabling = await rejectionMessage(listTriggerDefinitions()); + await completeAutomationMigration(); + const afterMigration = await listTriggerDefinitions(); + await setAutomationsEnabled(false); + const afterDisabling = await rejectionMessage(listTriggerDefinitions()); + // Leave the host enabled so a later test does not depend on this one's tail. + await setAutomationsEnabled(true); + + // The migration gate is checked first, so enabling alone changes nothing. + // Once both are open the host answers, and the answer is deliberately + // empty: it defines no event triggers today. + assert.deepStrictEqual({ + beforeAnyGate: beforeAnyGate.includes(MIGRATION_REQUIRED_MESSAGE), + afterEnabling: afterEnabling.includes(MIGRATION_REQUIRED_MESSAGE), + afterMigration, + afterDisabling: afterDisabling.includes(AUTOMATIONS_DISABLED_MESSAGE), + }, { + beforeAnyGate: true, + afterEnabling: true, + afterMigration: { items: [] }, + afterDisabling: true, + }); + }); + + conformanceTest(context, 'an automation created while automations are disabled gains its run operation when they are enabled', async function () { + await initializeRoot('automations-run-grant'); + // Granting `run` needs the enablement flag *and* completed migration. + // Migration cannot be undone on a host that has already migrated, so the + // enablement flag is the half of the gate a test can reproduce. + await openAutomationGates(); + await subscribeCatalog(); + await setAutomationsEnabled(false); + const resource = automationResource('run-grant'); + + context.client.clearReceived(); + const whileDisabled = await createAutomation(resource, buildDefinition('Run grant')); + context.client.clearReceived(); + await setAutomationsEnabled(true); + const afterEnabling = await waitForAutomationSet(resource, automation => automation.operations.includes(AutomationOperation.Run)); + + // The definition is authored either way; only the operations a client may + // offer for it change, and the host republishes the entry to say so. + assert.deepStrictEqual({ + whileDisabled: whileDisabled.operations, + afterEnabling: afterEnabling.operations, + title: afterEnabling.definition.title, + }, { + whileDisabled: UNGATED_OPERATIONS, + afterEnabling: GATED_OPERATIONS, + title: 'Run grant', + }); + }); + + conformanceTest(context, 'creating an automation is idempotent only for an identical definition', async function () { + await initializeRoot('automations-idempotent-create'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('idempotent-create'); + const definition = buildDefinition('Stable definition'); + const created = await createAutomation(resource, definition); + + context.client.clearReceived(); + const repeated = await createAutomation(resource, definition); + const conflictingDefinition = buildDefinition('Conflicting definition'); + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationCreateRequested, resource, definition: conflictingDefinition }, + }); + const rejected = await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.AutomationCreateRequested) + && getActionEnvelope(notification).channel === AUTOMATION_CATALOG_URI + && getActionEnvelope(notification).rejectionReason !== undefined, + ); + + assert.deepStrictEqual({ + createdAtUnchanged: repeated.createdAt === created.createdAt, + modifiedAtUnchanged: repeated.modifiedAt === created.modifiedAt, + title: repeated.definition.title, + rejection: getActionEnvelope(rejected).rejectionReason, + }, { + createdAtUnchanged: true, + modifiedAtUnchanged: true, + title: 'Stable definition', + rejection: `Automation already exists: ${resource}`, + }); + }); + + conformanceTest(context, 'updating and removing an automation keeps the catalogue authoritative', async function () { + await initializeRoot('automations-update-remove'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('update-remove'); + await createAutomation(resource, buildDefinition('Original title')); + + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationUpdateRequested, resource, changes: { title: 'Renamed title' } }, + }); + const updated = await waitForAutomationSet(resource); + context.client.clearReceived(); + context.client.dispatch({ + channel: AUTOMATION_CATALOG_URI, + clientSeq: nextClientSeq(), + action: { type: ActionType.AutomationRemoved, resource }, + }); + await context.client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.AutomationRemoved) || getActionEnvelope(notification).channel !== AUTOMATION_CATALOG_URI) { + return false; + } + const envelope = getActionEnvelope(notification) as { rejectionReason?: string; action: AutomationRemovedAction }; + return envelope.action.resource === resource && envelope.rejectionReason === undefined; + }); + const catalog = await subscribeCatalog(); + + // A patch replaces only the fields it names, and removal is republished as + // the same action so every subscriber converges on the host's catalogue. + assert.deepStrictEqual({ + updatedTitle: updated.definition.title, + updatedOperations: updated.operations, + updatedRuns: updated.runs, + survivesRemoval: entryFor(catalog, resource) !== undefined, + }, { + updatedTitle: 'Renamed title', + updatedOperations: GATED_OPERATIONS, + updatedRuns: [], + survivesRemoval: false, + }); + }); + + conformanceTest(context, 'fetchAutomationRuns acknowledges an automation that has never run and rejects an unknown one', async function () { + await initializeRoot('automations-fetch-runs'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('fetch-runs'); + await createAutomation(resource, buildDefinition('Fetch runs')); + const unknownResource = automationResource('fetch-runs-unknown'); + + const acknowledged = await context.client.call('fetchAutomationRuns', { + channel: AUTOMATION_CATALOG_URI, + automation: resource, + }); + const rejected = await rejectionMessage(context.client.call('fetchAutomationRuns', { + channel: AUTOMATION_CATALOG_URI, + automation: unknownResource, + })); + const catalog = await subscribeCatalog(); + + // The result is a bare acknowledgement by contract — run history reaches + // clients through `automation/set` — so an automation with no history has + // nothing to page and nothing to republish. + assert.deepStrictEqual({ + acknowledged, + runs: entryFor(catalog, resource)?.runs, + runsNextCursor: entryFor(catalog, resource)?.runsNextCursor, + rejected: rejected.includes(`Automation not found: ${unknownResource}`), + }, { + acknowledged: {}, + runs: [], + runsNextCursor: undefined, + rejected: true, + }); + }); + + conformanceTest(context, 'a created automation survives an agent host restart', async function () { + await initializeRoot('automations-restart'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('restart'); + const created = await createAutomation(resource, buildDefinition('Survives restart')); + + await context.restartServer(); + await initializeRoot('automations-restart-verify'); + const restored = entryFor(await subscribeCatalog(), resource); + + // The host persists a mutation before it publishes it, so a definition a + // client has seen is recoverable — with its operations — after a restart. + assert.deepStrictEqual({ + created: { title: created.definition.title, operations: created.operations }, + restored: restored && { title: restored.definition.title, operations: restored.operations }, + }, { + created: { title: 'Survives restart', operations: GATED_OPERATIONS }, + restored: { title: 'Survives restart', operations: GATED_OPERATIONS }, + }); + }); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index c05f711fccb4f7..f4365f8d07be95 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -30,6 +30,7 @@ import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentMergeConfigKey } from '../../../../common/agentMerge.js'; import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; @@ -70,6 +71,7 @@ interface IOperationsChangedAction { interface IObservedOperation { readonly id: string; + readonly group?: string; readonly scopes: readonly string[]; readonly status: string; } @@ -91,6 +93,15 @@ const CHANGESET_OPERATION_TIMEOUT_MS = 60_000; export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs } = context; + function parityTest(title: string, run: Mocha.AsyncFunc): void { + if (context.tier === 'parity') { + test(title, function () { + this.timeout(180_000); + return run.call(this); + }); + } + } + /** * Client sequence numbers must strictly increase for the lifetime of a * client, and the suite shares one across tests, so they cannot be @@ -143,6 +154,21 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); } + async function setRootConfig(values: Readonly>): Promise { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const clientSeq = nextClientSeq(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq, + action: { type: ActionType.RootConfigChanged, config: values }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === clientSeq, + ); + } + async function createWorktreeSessionIn(workspace: string, prefix: string): Promise { tempDirs.push(`${workspace}.worktrees`); context.client.setWorkingDirectory(workspace); @@ -789,6 +815,79 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ]); }); + parityTest('a GitHub remote with changes advertises pull request creation', async function () { + const workspace = createGitWorkspace('ahp-changeset-pr-ops-'); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/microsoft/vscode.git'], { cwd: workspace }); + const sessionUri = await createSessionIn(workspace, 'changeset-pr-ops'); + const uncommittedUri = buildUncommittedChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: uncommittedUri }); + await driveTurnToCompletion(context.client, sessionUri, 'turn-changeset-pr-materialize', 'Reply exactly "ready".', nextClientSeq()); + await runBangTurn(sessionUri, 'turn-changeset-pr-ops', writeFileCommand('pull-request.txt', 'PR'), nextClientSeq()); + + await waitForOperation(uncommittedUri, 'create-pr'); + const operations = (await changesetState(uncommittedUri)).operations ?? []; + const pullRequestOperations = operations + .filter(operation => operation.id.startsWith('create-pr') || operation.id === 'create-draft-pr') + .map(operation => ({ id: operation.id, group: operation.group, scopes: operation.scopes })); + + assert.deepStrictEqual(pullRequestOperations, [ + { id: 'create-pr', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-merge', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-squash', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-pr-auto-rebase', group: 'pull-request', scopes: ['changeset'] }, + { id: 'create-draft-pr', group: 'pull-request_draft', scopes: ['changeset'] }, + ]); + }); + + parityTest('enabling Agent Merge adds and removes its pull request operation', async function () { + const workspace = createGitWorkspace('ahp-changeset-agent-merge-'); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/microsoft/vscode.git'], { cwd: workspace }); + const sessionUri = await createSessionIn(workspace, 'changeset-agent-merge'); + const uncommittedUri = buildUncommittedChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: uncommittedUri }); + await driveTurnToCompletion(context.client, sessionUri, 'turn-changeset-agent-merge-materialize', 'Reply exactly "ready".', nextClientSeq()); + await runBangTurn(sessionUri, 'turn-changeset-agent-merge', writeFileCommand('agent-merge.txt', 'AGENT MERGE'), nextClientSeq()); + await waitForOperation(uncommittedUri, 'create-pr'); + + try { + await setRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const operation = await waitForOperation(uncommittedUri, 'create-pr-agent-merge'); + assert.deepStrictEqual({ + id: operation.id, + group: operation.group, + scopes: operation.scopes, + }, { + id: 'create-pr-agent-merge', + group: 'pull-request', + scopes: ['changeset'], + }); + } finally { + await setRootConfig({ [AgentMergeConfigKey.Enabled]: false }); + } + + await waitForOperationRemoved(uncommittedUri, 'create-pr-agent-merge'); + }); + + conformanceTest(context, 'a folder session advertises commit on its branch changeset', async function () { + const workspace = createGitWorkspace('ahp-changeset-branch-commit-'); + const sessionUri = await createSessionIn(workspace, 'changeset-branch-commit'); + const branchUri = buildBranchChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: branchUri }); + await runBangTurn(sessionUri, 'turn-changeset-branch-commit', writeFileCommand('branch-commit.txt', 'COMMIT'), 1); + + const operation = await waitForOperation(branchUri, 'commit'); + + assert.deepStrictEqual({ + id: operation.id, + group: operation.group, + scopes: operation.scopes, + }, { + id: 'commit', + group: 'commit', + scopes: ['changeset'], + }); + }); + conformanceTest(context, 'a branch with an upstream and no outgoing commits omits sync', async function () { const { workspace } = createRemoteGitWorkspace('ahp-sync-none'); const sessionUri = await createSessionIn(workspace, 'sync-none'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts new file mode 100644 index 00000000000000..1bba064842280e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/detachedWorktreeSuite.ts @@ -0,0 +1,305 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Detached worktrees: a git worktree the host materializes *before* any session + * owns it. + * + * A client that wants a worktree ready before the user sends their first + * message (the Agents window does this so a draft session already has a + * checkout) asks the host for one through the `vscode/…DetachedWorktree` + * extension methods. The worktree is keyed by an opaque handle rather than by a + * session, so its whole lifecycle — materialize, claim, archive/unarchive, + * delete, reconcile — is addressable over the protocol without a turn ever + * running. + * + * Everything here is host-local: git commands against a temporary repository + * plus the host's own per-handle record. Nothing crosses the model boundary, so + * every scenario is registered as a conformance-tier host-only test and runs + * against the strict shared empty fixture. + */ + +import assert from 'assert'; +import { execFileSync } from 'child_process'; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { getComparisonKey } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { + ClaimAgentHostDetachedWorktreeExtensionMethod, + CreateAgentHostDetachedWorktreeExtensionMethod, + DeleteAgentHostDetachedWorktreeExtensionMethod, + ReconcileAgentHostDetachedWorktreesExtensionMethod, + SetAgentHostDetachedWorktreeArchivedExtensionMethod, + type IAgentHostExtensionCommandMap, +} from '../../../../common/agentHostExtensionProtocol.js'; +import { isAgentDevContainerWorktreeHandle } from '../../../../common/meta/agentDevContainerWorktreeMeta.js'; +import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ROOT_STATE_URI, SessionLifecycle, type SessionState } from '../../../../common/state/sessionState.js'; +import { initTestGitRepo, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { vscodeAgentHostTarget } from '../harness/agentHostTarget.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +type CreateDetachedWorktreeResult = IAgentHostExtensionCommandMap[typeof CreateAgentHostDetachedWorktreeExtensionMethod]['result']; + +/** The `agents/` prefix the host puts in front of every branch it generates for an isolated checkout. */ +const AGENT_BRANCH_PREFIX = 'agents/'; + +/** + * Resolves a path through symlinks when it exists, and returns it unchanged + * when it does not. Temp directories are symlinked on macOS (`/var` -> + * `/private/var`), and these tests compare paths that git printed against paths + * the host returned, so both sides have to be canonicalized the same way — + * including after a worktree has been removed, when the path no longer resolves. + */ +function canonicalPath(candidate: string): string { + try { + return realpathSync(candidate); + } catch { + return candidate; + } +} + +function pathComparisonKey(candidate: string): string { + return getComparisonKey(URI.file(canonicalPath(candidate))); +} + +export function defineDetachedWorktreeTests(context: IAgentHostE2ETestContext): void { + // The detached-worktree family is an AHP *extension* method set rather than + // part of the core protocol, so only the VS Code agent host answers it. + if (context.targetId !== vscodeAgentHostTarget.id) { + return; + } + + const { config, createdSessions, tempDirs } = context; + const enabled = config.supportsWorktreeIsolation; + + let clientOrdinal = 0; + + /** A git repository with one commit, so a worktree has a branch point to check out. */ + function createGitWorkspace(prefix: string): string { + // Canonicalized up front: the host resolves the repository root through + // git, which reports the real path, and the worktree container is derived + // from that root. + const workspace = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(workspace, `${workspace}.worktrees`); + initTestGitRepo(workspace); + writeFileSync(join(workspace, 'seed.txt'), 'seed\n'); + execFileSync('git', ['add', '.'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: workspace }); + return workspace; + } + + function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + } + + /** The worktrees git currently has registered for `repository`, canonicalized. */ + function registeredWorktrees(repository: string): string[] { + return git(repository, 'worktree', 'list', '--porcelain') + .split('\n') + .filter(line => line.startsWith('worktree ')) + .map(line => pathComparisonKey(line.slice('worktree '.length).trim())); + } + + function isRegisteredWorktree(repository: string, worktreePath: string): boolean { + return registeredWorktrees(repository).includes(pathComparisonKey(worktreePath)); + } + + function branchExists(repository: string, branchName: string): boolean { + return git(repository, 'branch', '--list', branchName).length > 0; + } + + /** + * Creates a session configured for worktree isolation and stops before the + * first turn, which is exactly the state a detached worktree is requested + * from: the host has a session record but has deliberately not resolved its + * working directory yet. + */ + async function createUnstartedWorktreeSession(workspace: string, prefix: string): Promise { + context.client.setWorkingDirectory(workspace); + await context.client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId: `${prefix}-${config.provider}-${clientOrdinal++}`, + }, 30_000); + await context.client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: 'https://api.github.com', + token: config.githubToken ?? resolveGitHubToken(), + }, 30_000); + + const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); + await context.client.call('createSession', { + channel: sessionUri, + provider: config.provider, + workingDirectories: [URI.file(workspace).toString()], + config: { isolation: 'worktree', branch: git(workspace, 'branch', '--show-current') }, + }, 30_000); + createdSessions.push(sessionUri); + return sessionUri; + } + + function createDetachedWorktree(session: string, prompt: string): Promise { + return context.client.call(CreateAgentHostDetachedWorktreeExtensionMethod, { session, prompt }, 60_000); + } + + function claimDetachedWorktree(handle: string): Promise { + return context.client.call(ClaimAgentHostDetachedWorktreeExtensionMethod, { handle }, 30_000); + } + + function setDetachedWorktreeArchived(handle: string, archived: boolean): Promise { + return context.client.call(SetAgentHostDetachedWorktreeArchivedExtensionMethod, { handle, archived }, 60_000); + } + + function deleteDetachedWorktree(handle: string): Promise { + return context.client.call(DeleteAgentHostDetachedWorktreeExtensionMethod, { handle }, 60_000); + } + + function reconcileDetachedWorktrees(scope: string, activeHandles: readonly string[]): Promise { + return context.client.call(ReconcileAgentHostDetachedWorktreesExtensionMethod, { scope, activeHandles: [...activeHandles] }, 60_000); + } + + conformanceTest(context, 'creating a detached worktree materializes a checkout for an unstarted session', async function () { + const workspace = createGitWorkspace('ahp-detached-create-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-create'); + const sessionState = (await context.client.call('subscribe', { channel: sessionUri })).snapshot!.state as SessionState; + + const created = await createDetachedWorktree(sessionUri, 'summarize the seed file'); + const worktreePath = URI.parse(created.resource).fsPath; + + // The handle is opaque to the client, the checkout is a real git worktree + // of the session's repository, and it carries the repository's content — + // the three things a client needs before it can hand the directory to a + // user. The session itself stays unstarted: a detached worktree is not + // (yet) anybody's working directory. + assert.deepStrictEqual({ + sessionLifecycle: sessionState.lifecycle, + handleIsOpaqueId: isAgentDevContainerWorktreeHandle(created.handle), + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + checkedOutRepositoryContent: existsSync(join(worktreePath, 'seed.txt')), + onGeneratedAgentBranch: git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD').startsWith(AGENT_BRANCH_PREFIX), + isSeparateFromWorkspace: pathComparisonKey(worktreePath) !== pathComparisonKey(workspace), + }, { + sessionLifecycle: SessionLifecycle.Creating, + handleIsOpaqueId: true, + existsOnDisk: true, + registeredWithGit: true, + checkedOutRepositoryContent: true, + onGeneratedAgentBranch: true, + isSeparateFromWorkspace: true, + }); + }, enabled); + + conformanceTest(context, 'reconciling detached worktrees keeps every handle inside its retention window', async function () { + const workspace = createGitWorkspace('ahp-detached-reconcile-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-reconcile'); + + const held = await createDetachedWorktree(sessionUri, 'reconcile the held checkout'); + const dropped = await createDetachedWorktree(sessionUri, 'reconcile the dropped checkout'); + const heldPath = URI.parse(held.resource).fsPath; + const droppedPath = URI.parse(dropped.resource).fsPath; + await claimDetachedWorktree(held.handle); + + // Omitted handles remain claimable until the retention grace period expires. + await reconcileDetachedWorktrees(getComparisonKey(URI.parse(held.resource)), [held.handle]); + await reconcileDetachedWorktrees(getComparisonKey(URI.parse(dropped.resource)), []); + + await claimDetachedWorktree(dropped.handle); + + assert.deepStrictEqual({ + heldExists: existsSync(heldPath), + droppedExists: existsSync(droppedPath), + heldRegistered: isRegisteredWorktree(workspace, heldPath), + droppedRegistered: isRegisteredWorktree(workspace, droppedPath), + areDistinctCheckouts: pathComparisonKey(heldPath) !== pathComparisonKey(droppedPath), + }, { + heldExists: true, + droppedExists: true, + heldRegistered: true, + droppedRegistered: true, + areDistinctCheckouts: true, + }); + }, enabled); + + conformanceTest(context, 'archiving a detached worktree removes its checkout and unarchiving recreates it', async function () { + const workspace = createGitWorkspace('ahp-detached-archive-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-archive'); + + const created = await createDetachedWorktree(sessionUri, 'archive and restore this checkout'); + const worktreePath = URI.parse(created.resource).fsPath; + const branchName = git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'); + + // Archiving reclaims the disk a dormant checkout is holding, but it must + // preserve the branch: that branch is the only thing that makes the + // checkout reconstructible, so dropping it would turn "archive" into + // "discard". + await setDetachedWorktreeArchived(created.handle, true); + const archived = { + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + branchPreserved: branchExists(workspace, branchName), + }; + + // Unarchiving puts the same branch back at the same path, so a client that + // stored the path before archiving still resolves to a valid checkout. + await setDetachedWorktreeArchived(created.handle, false); + const restored = { + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + checkedOutRepositoryContent: existsSync(join(worktreePath, 'seed.txt')), + branch: git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'), + }; + + assert.deepStrictEqual({ archived, restored }, { + archived: { + existsOnDisk: false, + registeredWithGit: false, + branchPreserved: true, + }, + restored: { + existsOnDisk: true, + registeredWithGit: true, + checkedOutRepositoryContent: true, + branch: branchName, + }, + }); + }, enabled); + + conformanceTest(context, 'deleting a detached worktree removes its checkout and forgets its handle', async function () { + const workspace = createGitWorkspace('ahp-detached-delete-'); + const sessionUri = await createUnstartedWorktreeSession(workspace, 'detached-delete'); + + const created = await createDetachedWorktree(sessionUri, 'delete this checkout'); + const worktreePath = URI.parse(created.resource).fsPath; + + await deleteDetachedWorktree(created.handle); + + // Deletion takes the checkout off disk *and* drops the host's record for + // the handle. The record is not directly observable, so the oracle is the + // handle no longer resolving — a client cannot claim what the host forgot. + await assert.rejects(claimDetachedWorktree(created.handle), /Unknown detached worktree handle/); + await assert.rejects(claimDetachedWorktree(generateUuid()), /Unknown detached worktree handle/); + + // Deletion is idempotent: a client retrying after a dropped response, or + // two clients reacting to the same removal, must not turn the second + // attempt into an error. + await deleteDetachedWorktree(created.handle); + + assert.deepStrictEqual({ + existsOnDisk: existsSync(worktreePath), + registeredWithGit: isRegisteredWorktree(workspace, worktreePath), + repositoryStillIntact: existsSync(join(workspace, 'seed.txt')), + }, { + existsOnDisk: false, + registeredWithGit: false, + repositoryStillIntact: true, + }); + }, enabled); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts index a17383178bd318..9a7102e322dec2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts @@ -58,7 +58,8 @@ function fileOperationTest(context: IAgentHostE2ETestContext, title: string, run export function defineFileOperationsTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs, portableShellToolReplayEnabled, isWindows } = context; - const shellOutputOracleAvailable = !(isWindows && config.provider === 'copilotcli'); + const shellResultTextAvailable = !config.shellToolResultTextUnreliable; + const shellOutputOracleAvailable = shellResultTextAvailable && !(isWindows && config.provider === 'copilotcli'); const BEHAVIOR_SNAPSHOT = { profile: 'behavior', // Codex occasionally omits command completion; direct filesystem and response assertions are the success oracle. @@ -337,7 +338,7 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo success: true, }); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); - }); + }, shellResultTextAvailable); fileOperationTest(context, 'reads a file from a nested directory', async function () { this.timeout(180_000); @@ -365,7 +366,7 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo success: true, }); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); - }); + }, shellResultTextAvailable); (portableShellToolReplayEnabled && shellOutputOracleAvailable ? test : test.skip)('lists workspace entries', async function () { this.timeout(180_000); @@ -468,7 +469,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don success: true, }); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); - }); + }, shellResultTextAvailable); fileOperationTest(context, 'counts lines in a file', async function () { this.timeout(180_000); @@ -499,7 +500,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don success: true, }); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); - }); + }, shellResultTextAvailable); fileOperationTest(context, 'handles a missing file without a session error', async function () { this.timeout(180_000); @@ -525,7 +526,7 @@ Use your file creation tool; do not run a shell command. Then reply exactly "don success: config.fileOperationStrategy === 'shell', }); await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); - }); + }, shellResultTextAvailable); fileOperationTest(context, 'creates a new text file', async function () { this.timeout(180_000); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts index af203abb603ff2..2d5da44e2c60e5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/hostFeaturesSuite.ts @@ -11,10 +11,12 @@ import { join } from '../../../../../../base/common/path.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentHostConfigKey } from '../../../../common/agentHostCustomizationConfig.js'; import { AgentHostCopilotMultiRootEnabledConfigKey } from '../../../../common/agentHostSchema.js'; +import { deriveGitHubEndpoints, gitHubCopilotResource } from '../../../../common/githubEndpoints.js'; import { CompletionItemKind, type CompletionsResult, type InitializeResult, type ResolveSessionConfigResult, type SessionConfigCompletionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ActionType } from '../../../../common/state/sessionActions.js'; +import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageAttachmentKind, ROOT_STATE_URI, ToolCallConfirmationReason, type TerminalState, type ToolResultContent } from '../../../../common/state/sessionState.js'; import { createRealSession, @@ -140,6 +142,26 @@ export function defineHostFeaturesTests(context: IAgentHostE2ETestContext): void }); }); + conformanceTest(context, 'configuring a GitHub Enterprise host asks the client to re-authenticate', async function () { + const enterpriseUri = 'https://enterprise.example.com'; + await createSession('enterprise-auth-required'); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.clearReceived(); + try { + const required = context.client.waitForNotification(notification => notification.method === 'auth/required'); + await setRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: enterpriseUri }); + const notification = await required; + + assert.deepStrictEqual(notification.params as AuthRequiredParams, { + channel: ROOT_STATE_URI, + resource: gitHubCopilotResource(deriveGitHubEndpoints(enterpriseUri)), + reason: AuthRequiredReason.Required, + }); + } finally { + await setRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: '' }); + } + }); + conformanceTest(context, 'workspace file completions are filtered, attached, and cached', async function () { const workspace = createWorkspace('ahp-file-completions-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 04ddaa514b69f0..9fd2eaaf647d11 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -10,10 +10,13 @@ import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey } from '../../../../common/agentHostSchema.js'; import { FEEDBACK_ANNOTATION_META_KEY, type IFeedbackAnnotationMeta } from '../../../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../../../common/annotationsUri.js'; import { buildOpenSessionLinkUri } from '../../../../common/openSessionLink.js'; -import { SessionServerToolName } from '../../../../common/serverToolNames.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import { ArtifactServerToolName, SessionServerToolName } from '../../../../common/serverToolNames.js'; +import { readSessionArtifacts } from '../../../../common/sessionArtifacts.js'; import type { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType, NotificationType, type ChatToolCallCompleteAction, type ChatToolCallStartAction, type SessionAddedParams, type StateAction } from '../../../../common/state/sessionActions.js'; import { @@ -101,7 +104,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void return { sessionUri, chatUri, workspace }; } - async function createSession(prefix: string, stableResource = false): Promise { + async function createSession(prefix: string, stableResource = false, beforeCreateSession?: () => Promise): Promise { const workspace = mkdtempSync(join(tmpdir(), `ahp-server-tools-${prefix}-`)); tempDirs.push(workspace); if (!stableResource) { @@ -111,6 +114,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void `server-tools-${prefix}-${config.provider}`, createdSessions, URI.file(workspace), + beforeCreateSession, ); context.client.clearReceived(); return { sessionUri, chatUri: buildDefaultChatUri(sessionUri), workspace }; @@ -157,6 +161,11 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void ); } + async function setRootConfig(values: Readonly>): Promise { + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + await dispatchAndWait(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: values }); + } + async function seedFeedback(sessionUri: string, options: ISeedFeedbackOptions): Promise { const annotationsUri = buildAnnotationsUri(sessionUri); await context.client.call('subscribe', { channel: annotationsUri }); @@ -267,6 +276,148 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(toolNames, [...feedbackToolNames, ...sessionToolNames]); }); + serverToolTest('server tool: rename_chat renames the chat it runs in', async function () { + try { + const session = await createSession('rename-chat', false, () => setRootConfig({ + [AgentHostActiveAgentTitleGenerationConfigKey]: true, + })); + await driveTurnToCompletion( + context.client, + session.sessionUri, + 'turn-rename-chat-seed', + '/rename Seeded Chat', + reserveClientSequenceBlock(), + ); + const { tool } = await driveServerTool( + session, + 'turn-rename-chat', + 'Call the rename_chat tool exactly once with title "Coverage audit" and automatic false, then reply with exactly "renamed".', + SessionServerToolName.RenameChat, + ); + const renamed = await retry(async () => { + const sessionTitle = (await sessionState(session.sessionUri)).title; + const chatTitle = (await chatState(session.chatUri)).title; + if (sessionTitle !== 'Coverage audit' || chatTitle !== 'Coverage audit') { + throw new Error('The chat rename has not completed'); + } + return { sessionTitle, chatTitle }; + }, 100, 100); + + assert.deepStrictEqual({ + succeeded: tool.completion.result.success, + ...renamed, + }, { + succeeded: true, + sessionTitle: 'Coverage audit', + chatTitle: 'Coverage audit', + }); + } finally { + await setRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: false }); + } + }); + + serverToolTest('server tool: add_artifact_or_reference records a reference in session state', async function () { + try { + const session = await createSession('artifact-add', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + await driveServerTool( + session, + 'turn-artifact-add', + 'Call add_artifact_or_reference exactly once with type "website", label "Agent Host guide", isArtifact false, and link "https://example.com/agent-host". Then reply with exactly "recorded".', + ArtifactServerToolName.AddArtifactOrReference, + { result: [/Added reference:/, /Agent Host guide/, /https:\/\/example\.com\/agent-host/] }, + ); + const [artifact] = readSessionArtifacts((await sessionState(session.sessionUri))._meta); + + assert.deepStrictEqual({ + artifact: artifact && { + type: artifact.type, + label: artifact.label, + isArtifact: artifact.isArtifact, + link: artifact.link, + }, + }, { + artifact: { + type: 'website', + label: 'Agent Host guide', + isArtifact: false, + link: 'https://example.com/agent-host', + }, + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + + serverToolTest('server tool: add_artifact_or_reference rejects a session-management link', async function () { + try { + const session = await createSession('artifact-reject-session', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + const { tool } = await driveServerTool( + session, + 'turn-artifact-reject-session', + 'Call add_artifact_or_reference exactly once with type "resource", label "Spawned session", isArtifact true, and uri "agent-host-session://copilot/spawned". Then reply with exactly "rejected".', + ArtifactServerToolName.AddArtifactOrReference, + { + success: false, + result: [/sessions and chats created with session-management tools must not be recorded/], + }, + ); + + assert.deepStrictEqual({ + succeeded: tool.completion.result.success, + artifacts: readSessionArtifacts((await sessionState(session.sessionUri))._meta), + }, { + succeeded: false, + artifacts: [], + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + + serverToolTest('server tool: list and remove round-trip a recorded reference', async function () { + try { + const session = await createSession('artifact-list-remove', false, () => setRootConfig({ + [AgentHostArtifactToolsConfigKey]: true, + })); + await driveServerTool( + session, + 'turn-artifact-list-remove-add', + 'Call add_artifact_or_reference exactly once with type "website", label "Design notes", isArtifact false, and link "https://example.com/design". Then reply with exactly "added".', + ArtifactServerToolName.AddArtifactOrReference, + ); + const [artifact] = readSessionArtifacts((await sessionState(session.sessionUri))._meta); + assert.ok(artifact); + const listed = await driveServerTool( + session, + 'turn-artifact-list-remove-list', + 'Call list_artifacts_and_references exactly once, then reply with exactly "listed".', + ArtifactServerToolName.ListArtifactsAndReferences, + ); + const removed = await driveServerTool( + session, + 'turn-artifact-list-remove-remove', + `Call remove_artifact_or_reference exactly once with id "${artifact.id}", then reply with exactly "removed".`, + ArtifactServerToolName.RemoveArtifactOrReference, + ); + + assert.deepStrictEqual({ + listed: listed.tool.resultText.includes(`${artifact.id} (website, reference) Design notes — https://example.com/design`), + removed: removed.tool.resultText.includes(`Removed reference: ${artifact.id}`), + artifacts: readSessionArtifacts((await sessionState(session.sessionUri))._meta), + }, { + listed: true, + removed: true, + artifacts: [], + }); + } finally { + await setRootConfig({ [AgentHostArtifactToolsConfigKey]: false }); + } + }); + serverToolTest('server tool: listComments executes in-process with an empty annotation channel', async function () { const session = await createSession('comments-empty'); const { tool } = await driveServerTool( @@ -875,9 +1026,11 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void return request; }, 50, 600); const childState = await waitForChatIdle(buildDefaultChatUri(child.resource)); + const childSessionState = await sessionState(child.resource); assert.deepStrictEqual({ sawPendingConfirmation: turn.sawPendingConfirmation, provider: child.provider, + isolation: childSessionState.config?.values[SessionConfigKey.Isolation], messages: childState.turns.map(turn => turn.message.text), title: childState.title, childRequestModel: childRequest.model, @@ -885,6 +1038,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }, { sawPendingConfirmation: true, provider: model.provider, + isolation: 'folder', messages: [childPrompt], title: 'Created Child', childRequestModel: createSessionModelWireTarget, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 21ad491425261f..2ab33a1f90505a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -9,9 +9,10 @@ import { tmpdir } from 'os'; import { retry, timeout } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import type { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import type { SessionSummaryChangedParams } from '../../../../common/state/protocol/channels-root/notifications.js'; import { ActionType } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, type ChatState, type SessionState } from '../../../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, type ChatState, type SessionState } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { createRealSession, driveTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; @@ -131,6 +132,41 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) }); }); + test('archiving a never-restored session survives a host restart', async function () { + this.timeout(240_000); + const workspace = fs.mkdtempSync(`${tmpdir()}/ahp-archive-unrestored-`); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, `archive-unrestored-${config.provider}`, createdSessions, URI.file(workspace)); + await driveTurnToCompletion(context.client, sessionUri, 'turn-archive-unrestored-seed', 'Reply exactly "READY".', 1); + await restartAndInitialize(`archive-unrestored-reconnect-${config.provider}`, workspace); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const before = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + assert.strictEqual(before.items.some(item => item.resource === sessionUri), true); + context.client.clearReceived(); + context.client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { type: ActionType.SessionIsArchivedChanged, isArchived: true }, + }); + await context.client.waitForNotification(notification => + notification.method === 'root/sessionSummaryChanged' + && (notification.params as SessionSummaryChangedParams).session === sessionUri + && (((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsArchived) !== 0, + ); + + await restartAndInitialize(`archive-unrestored-verify-${config.provider}`, workspace); + const after = await context.client.call('listSessions', { channel: ROOT_STATE_URI, includeArchived: true }); + const restored = after.items.find(item => item.resource === sessionUri); + + assert.deepStrictEqual({ + restored: restored !== undefined, + isArchived: restored !== undefined && (restored.status & SessionStatus.IsArchived) !== 0, + }, { + restored: true, + isArchived: true, + }); + }); + const peerChatPersistenceEnabled = config.supportsMultipleChats && (config.supportsMultipleChatsE2E !== false || RECORDING) && (!(context.isWindows && config.provider === 'copilotcli') || context.runKnownIssueTests); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts index af78b995a26fc4..c43bbf2e1e0465 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts @@ -120,7 +120,7 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { // even though the tool call itself completes. // // Re-enabling on Windows needs the missing terminal resource understood. - (config.supportsWorktreeIsolation && !isWindows && portableShellToolReplayEnabled ? test : test.skip)('worktree session uses the resolved worktree as working directory', async function () { + (config.supportsWorktreeIsolation && !isWindows && portableShellToolReplayEnabled && !config.shellToolResultTextUnreliable ? test : test.skip)('worktree session uses the resolved worktree as working directory', async function () { this.timeout(120_000); const tempDir = mkdtempSync(`${tmpdir()}/ahp-wt-test-`); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 6f4d8c1357fd5d..f4e3969ea7782f 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -82,9 +82,10 @@ suite('SessionServerTools', () => { if (!config) { return undefined; } - const { _meta, ...rest } = config; + const { _meta, workingDirectories, ...rest } = config; return { ...rest, + ...(workingDirectories !== undefined ? { workingDirectories: workingDirectories.map(directory => directory.toString()) } : {}), createdBySession: readSessionCreationReference(_meta), }; } @@ -606,7 +607,7 @@ suite('SessionServerTools', () => { const text = await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { relationship: 'independent', workspace: workspace.toString(), prompt: 'do it', title: 'New Task', model: 'gpt-4o' }); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', model: { id: 'gpt-4o' }, createdBySession: { @@ -638,6 +639,64 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('create_session falls back to an explicit workspace when listing sessions fails', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + let created: IAgentCreateSessionConfig | undefined; + let catalogRequests = 0; + const accessor = createAccessor({ + listSessions: async () => { + catalogRequests++; + throw new Error('Provider codex cannot enumerate its native session catalog yet'); + }, + onCreate: config => { created = config; }, + }); + const group = createSessionServerToolGroup(accessor); + + const text = await group.execute(stateManager, executionContext('codex:/caller'), SessionServerToolName.CreateSession, { + relationship: 'independent', + workspace: workspace.toString(), + prompt: 'do it', + title: 'New Task', + }); + + assert.deepStrictEqual({ + catalogRequests, + workingDirectories: created?.workingDirectories?.map(directory => directory.toString()), + result: text.startsWith('New session created'), + }, { + catalogRequests: 1, + workingDirectories: [workspace.toString()], + result: true, + }); + store.dispose(); + }); + + test('create_session prefers a URI-shaped project display name from the catalog', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const project = URI.parse('file:///projects/repo-main'); + let created: IAgentCreateSessionConfig | undefined; + const accessor = createAccessor({ + listSessions: async () => [{ + ...sessionMeta('project', SessionStatus.Idle, URI.parse('file:///worktrees/repo-main')), + project: { uri: project, displayName: 'repo:main' }, + }], + onCreate: config => { created = config; }, + }); + const group = createSessionServerToolGroup(accessor); + + await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { + relationship: 'independent', + workspace: 'repo:main', + prompt: 'do it', + title: 'New Task', + }); + + assert.deepStrictEqual(created?.workingDirectories?.map(directory => directory.toString()), [project.toString()]); + store.dispose(); + }); + test('create_session and send_message results are neutral, non-terminal statements (issue #330138)', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); @@ -700,7 +759,7 @@ suite('SessionServerTools', () => { }, { creationSource: source.toString(), created: { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', model: { id: 'gpt-inherited' }, createdBySession: { @@ -732,7 +791,7 @@ suite('SessionServerTools', () => { assert.deepStrictEqual(created.map(createConfigSnapshot), [ { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/source', @@ -741,7 +800,7 @@ suite('SessionServerTools', () => { config: { [SessionConfigKey.Isolation]: 'worktree' }, }, { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/quick-chat', @@ -765,7 +824,7 @@ suite('SessionServerTools', () => { await applyCreateSessionTool(accessor, { relationship: 'independent', workspace: workspace.toString(), prompt: 'do it', title: 'Provider Task' }, URI.parse('claude:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'claude', createdBySession: { session: 'claude:/source', @@ -794,7 +853,7 @@ suite('SessionServerTools', () => { }, URI.parse('copilot:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [gitWorkspace], + workingDirectories: [gitWorkspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/source', @@ -828,7 +887,7 @@ suite('SessionServerTools', () => { }, URI.parse('copilot:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [remoteProject], + workingDirectories: [remoteProject.toString()], provider: 'claude', model: { id: 'claude-sonnet' }, config: { [SessionConfigKey.Isolation]: 'folder' }, @@ -1453,6 +1512,27 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('get_current_session does not depend on listing sessions', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const metadata = { ...sessionMeta('s1', SessionStatus.Idle, workspace), session: URI.parse('codex:/s1') }; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => { throw new Error('Provider codex cannot enumerate its native session catalog yet'); }, + getSession: async session => session.toString() === metadata.session.toString() ? metadata : undefined, + })); + + const text = await group.execute(stateManager, executionContext('codex:/s1'), SessionServerToolName.GetCurrentSession, {}); + + assert.deepStrictEqual(JSON.parse(text), { + session: 'codex:/s1', + openLink: 'agent-host-session://codex/s1', + title: 'title-s1', + status: 'idle', + workingDirectory: 'file:///workspace/app', + }); + store.dispose(); + }); + test('getDeleteSessionArgs validates and refuses the current session', () => { const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace), sessionMeta('s2', SessionStatus.Idle, workspace)]; assert.strictEqual(getDeleteSessionArgs({ session: 'copilot:/s2' }, sessions).toString(), 'copilot:/s2'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index d186581be34e18..d9505fe64fcabd 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -456,6 +456,10 @@ export function isInMemoryStorageScope(scope: BrowserViewStorageScope): boolean return scope === BrowserViewStorageScope.Ephemeral || scope === BrowserViewStorageScope.Agent; } +export function isBrowserViewStorageScopeShareableWithAgent(scope: BrowserViewStorageScope, networkFilteringEnabled: boolean): boolean { + return !networkFilteringEnabled || scope === BrowserViewStorageScope.Agent; +} + /** Selects an existing browser context by ID or resolves one from storage options. */ export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions; diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 9184d834da4968..8be17ee4308ed0 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -19,6 +19,7 @@ import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRem import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { localize } from '../../../nls.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; /** * Holds an Electron session along with its storage scope and unique browser @@ -66,8 +67,8 @@ export class BrowserSession { * Cleans up stale {@link _byId} entries when the Electron session * they point to is garbage-collected. */ - private static readonly _finalizer = new FinalizationRegistry((id) => { - BrowserSession._byId.delete(id); + private static readonly _finalizer = new FinalizationRegistry(id => { + this._byId.delete(id); }); /** @@ -115,6 +116,18 @@ export class BrowserSession { return ids; } + /** Update network filtering on all live browser sessions. */ + static updateNetworkFiltering(): void { + for (const [id, ref] of BrowserSession._byId) { + const browserSession = ref.deref(); + if (browserSession) { + browserSession.updateNetworkFilter(); + } else { + BrowserSession._byId.delete(id); + } + } + } + /** * Get or create the singleton global-scope session. */ @@ -229,6 +242,7 @@ export class BrowserSession { private readonly _history: BrowserSessionHistory; private readonly _remote: BrowserSessionRemote; private readonly _permissions: BrowserSessionPermissions; + private _networkFilterEnabled = false; /** * @deprecated Don't use this directly. Create sessions via the static factory methods. @@ -244,11 +258,13 @@ export class BrowserSession { readonly electronSession: Electron.Session, /** Resolved storage scope. */ readonly storageScope: BrowserViewStorageScope, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { this._trust = new BrowserSessionTrust(this); this._history = new BrowserSessionHistory(this); this._remote = new BrowserSessionRemote(this); this._permissions = new BrowserSessionPermissions(this); + this.updateNetworkFilter(); this.configure(); BrowserSession.knownSessions.add(electronSession); BrowserSession._bySession.set(electronSession, this); @@ -289,7 +305,32 @@ export class BrowserSession { } /** - * Apply the permission policy and preload scripts to the session. + * Dynamically apply network filtering to Agent sessions. + */ + private updateNetworkFilter(): void { + if (this.storageScope !== BrowserViewStorageScope.Agent) { + return; + } + + const enabled = this.agentNetworkFilterService.isEnabled(); + if (this._networkFilterEnabled === enabled) { + return; + } + this._networkFilterEnabled = enabled; + this.electronSession.webRequest.onBeforeRequest(enabled ? (details, callback) => { + let uri: URI; + try { + uri = URI.parse(details.url, true); + } catch { + callback({ cancel: true }); + return; + } + callback({ cancel: !this.agentNetworkFilterService.isUriAllowed(uri) }); + } : null); + } + + /** + * Apply permissions, protocols, and preload scripts to the session. */ private configure(): void { this._permissions.configure(this.electronSession); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index a42b41d8cdb700..75fb879442b9be 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -89,6 +89,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (this._isActive) { return; } + this._validateAgentGroupStorageScope(); this._isActive = true; const views = await this.browserViewMainService.getBrowserViews(this.targetContext.host.windowId); @@ -158,6 +159,9 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (!view) { throw new Error(`Browser view ${viewId} not found`); } + if (this.filter.audience?.type === 'agent') { + this.browserViewMainService.validateAgentAccess(view); + } this.views.set(view.id, view); this.knownContextIds.add(view.session.id); @@ -274,9 +278,17 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createTarget(url: string, browserContextId?: string): Promise { + this._validateAgentGroupStorageScope(); if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); } + if (browserContextId && this.filter.audience?.type === 'agent') { + const browserSession = BrowserSession.get(browserContextId); + if (!browserSession) { + throw new Error(`Browser context ${browserContextId} no longer exists`); + } + this.browserViewMainService.validateAgentStorageScope(browserSession.storageScope); + } const target = await this.browserViewMainService.createTarget(url, { ...this.targetContext, @@ -317,6 +329,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createBrowserContext(): Promise { + this._validateAgentGroupStorageScope(); const contextId = generateUuid(); const sessionSelector = this.targetContext.session; const usesAgentStorage = typeof sessionSelector === 'string' @@ -330,6 +343,24 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I return browserSession.id; } + private _validateAgentGroupStorageScope(): void { + if (this.filter.audience?.type === 'agent') { + this.browserViewMainService.validateAgentStorageScope(this._getTargetStorageScope()); + } + } + + private _getTargetStorageScope(): BrowserViewStorageScope { + if (typeof this.targetContext.session === 'string') { + const browserSession = BrowserSession.get(this.targetContext.session); + if (!browserSession) { + throw new Error(`Browser session ${this.targetContext.session} not found`); + } + return browserSession.storageScope; + } + + return this.targetContext.session.scope; + } + async disposeBrowserContext(browserContextId: string): Promise { if (!this.ownedContextIds.has(browserContextId)) { throw new Error('Can only dispose browser contexts created by this group'); diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index d7676a4a66c9cb..b929bfa8cb9361 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { BrowserViewSessionSelector, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; +import { BrowserViewSessionSelector, BrowserViewStorageScope, isBrowserViewStorageScopeShareableWithAgent, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -25,6 +25,7 @@ import { BrowserViewInspectElementId } from './browserViewInspector.js'; import { equals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -35,6 +36,12 @@ export interface IBrowserViewMainService extends IBrowserViewService { /** Create a new target and return it. */ createTarget(url: string, context: IBrowserViewCreationContext): Promise; + + /** Validate that a view can be exposed to an agent audience. */ + validateAgentAccess(view: BrowserView): void; + + /** Validate that a storage scope can be exposed to an agent. */ + validateAgentStorageScope(storageScope: BrowserViewStorageScope): void; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -68,8 +75,13 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService, @ILogService private readonly logService: ILogService, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { super(); + this._register(this.agentNetworkFilterService.onDidChange(() => { + BrowserSession.updateNetworkFiltering(); + this._updateAgentAccess(); + })); } async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { @@ -96,7 +108,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa if (browserSession) { return browserSession; } - return BrowserSession.getOrCreateEphemeral(this.instantiationService, id); + throw new Error(`Browser session ${selector} not found`); } const hostWindow = this.windowsMainService.getWindowById(hostWindowId); @@ -235,7 +247,21 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } async setAudience(id: string, audience: IBrowserViewAudience, enabled: boolean): Promise { - this._getBrowserView(id).setAudience(audience, enabled); + const view = this._getBrowserView(id); + if (enabled && audience.type === 'agent') { + this.validateAgentAccess(view); + } + view.setAudience(audience, enabled); + } + + validateAgentAccess(view: BrowserView): void { + this.validateAgentStorageScope(view.session.storageScope); + } + + validateAgentStorageScope(storageScope: BrowserViewStorageScope): void { + if (!isBrowserViewStorageScopeShareableWithAgent(storageScope, this.agentNetworkFilterService.isEnabled())) { + throw new Error('Browser session cannot be exposed to an agent because it does not enforce the current network policy.'); + } } async destroyBrowserView(id: string): Promise { @@ -474,7 +500,11 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { + const hasAgentAccess = options.owner.type === 'agent' || options.initialAudiences?.some(audience => audience.type === 'agent') === true; const browserSession = this._resolveBrowserSession(id, options.host.windowId, options.session); + if (hasAgentAccess) { + this.validateAgentStorageScope(browserSession.storageScope); + } const view = this._createNativeBrowserView(id, options.host, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); if (options.initialAudiences) { view.setAudiences(options.initialAudiences); @@ -495,6 +525,26 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } + private _updateAgentAccess(): void { + if (!this.agentNetworkFilterService.isEnabled()) { + return; + } + + const staleAgentViewIds: string[] = []; + for (const [, view] of this.browserViews) { + if (isBrowserViewStorageScopeShareableWithAgent(view.session.storageScope, true)) { + continue; + } + view.setAudience({ type: 'agent' }, false); + if (view.owner.type === 'agent') { + staleAgentViewIds.push(view.id); + } + } + for (const viewId of staleAgentViewIds) { + this.browserViews.deleteAndDispose(viewId); + } + } + private async openNew( url: string, context: IBrowserViewCreationContext, diff --git a/src/vs/platform/browserView/test/common/browserView.test.ts b/src/vs/platform/browserView/test/common/browserView.test.ts index ed064c59023f7d..333f75c2327c33 100644 --- a/src/vs/platform/browserView/test/common/browserView.test.ts +++ b/src/vs/platform/browserView/test/common/browserView.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; +import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isBrowserViewStorageScopeShareableWithAgent, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; suite('BrowserView', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -89,4 +89,24 @@ suite('BrowserView', () => { agent: true, }); }); + + test('only shares Agent storage when network filtering is enabled', () => { + assert.deepStrictEqual({ + filteringDisabled: Object.fromEntries(Object.values(BrowserViewStorageScope).map(scope => [scope, isBrowserViewStorageScopeShareableWithAgent(scope, false)])), + filteringEnabled: Object.fromEntries(Object.values(BrowserViewStorageScope).map(scope => [scope, isBrowserViewStorageScopeShareableWithAgent(scope, true)])), + }, { + filteringDisabled: { + global: true, + workspace: true, + ephemeral: true, + agent: true, + }, + filteringEnabled: { + global: false, + workspace: false, + ephemeral: false, + agent: true, + }, + }); + }); }); diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index 4a54e7dee81daa..a7ccf5cd8a11b8 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -211,7 +211,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { let authenticated = true; for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { const headers: Record = { - 'Accept': 'text/plain, application/octet-stream', + 'Accept': authenticated ? 'application/vnd.github+json' : 'text/plain, application/octet-stream', 'Cache-Control': 'no-store', 'X-GitHub-Api-Version': defaultApiVersion, }; diff --git a/src/vs/platform/github/test/node/pullRequestMutationService.test.ts b/src/vs/platform/github/test/node/pullRequestMutationService.test.ts index cbde2a96a7e8b6..f88c411443b39a 100644 --- a/src/vs/platform/github/test/node/pullRequestMutationService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestMutationService.test.ts @@ -608,13 +608,20 @@ suite('PullRequestMutationService', () => { gitHubRestStep({ method: 'GET', path: '/repos/octo/repo/actions/jobs/20/logs', + assert: request => assert.strictEqual(request.headers.accept, 'application/vnd.github+json'), response: gitHubRedirectResponse(`${download.apiBaseUrl}/signed/log`), }), ); download.enqueue(gitHubRestStep({ method: 'GET', path: '/signed/log', - assert: request => assert.strictEqual(request.headers.authorization, undefined), + assert: request => assert.deepStrictEqual({ + accept: request.headers.accept, + authorization: request.headers.authorization, + }, { + accept: 'text/plain, application/octet-stream', + authorization: undefined, + }), response: gitHubRawResponse('::add-mask::supersecret\nsupersecret\ntoken=visible\nghp_1234567890123456'), })); const { ref, service } = setup(server); diff --git a/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts index 31fc9e2604a651..71bab1a57bd5c7 100644 --- a/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts +++ b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts @@ -15,6 +15,7 @@ export interface INativeMcpDiscoveryData { // platform and homedir are duplicated by the remote/native environment, but here for convenience platform: Platform; homedir: URI; + copilotHome?: URI; winAppData?: URI; xdgHome?: URI; } diff --git a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts index 987f25f9a7cd33..71e4cca181566b 100644 --- a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts +++ b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts @@ -4,30 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { homedir } from 'os'; -import { platform } from '../../../base/common/platform.js'; +import { IProcessEnvironment, platform } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { INativeMcpDiscoveryData, INativeMcpDiscoveryHelperService } from '../common/nativeMcpDiscoveryHelper.js'; export class NativeMcpDiscoveryHelperService implements INativeMcpDiscoveryHelperService { declare readonly _serviceBrand: undefined; - constructor() { } + constructor(private readonly environment: IProcessEnvironment = process.env) { } load(): Promise { return Promise.resolve({ platform, homedir: URI.file(homedir()), + copilotHome: this.uriFromEnvVariable('COPILOT_HOME'), winAppData: this.uriFromEnvVariable('APPDATA'), xdgHome: this.uriFromEnvVariable('XDG_CONFIG_HOME'), }); } private uriFromEnvVariable(varName: string) { - const envVar = process.env[varName]; + const envVar = this.environment[varName]; if (!envVar) { return undefined; } return URI.file(envVar); } } - diff --git a/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts b/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts new file mode 100644 index 00000000000000..4f1f2d350ce5d6 --- /dev/null +++ b/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NativeMcpDiscoveryHelperService } from '../../node/nativeMcpDiscoveryHelperService.js'; + +suite('NativeMcpDiscoveryHelperService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads native configuration roots from the process environment', async () => { + const data = await new NativeMcpDiscoveryHelperService({ + COPILOT_HOME: '/custom/copilot', + APPDATA: '/custom/app-data', + XDG_CONFIG_HOME: '/custom/config', + }).load(); + + assert.deepStrictEqual({ + copilotHome: data.copilotHome?.path, + winAppData: data.winAppData?.path, + xdgHome: data.xdgHome?.path, + }, { + copilotHome: '/custom/copilot', + winAppData: '/custom/app-data', + xdgHome: '/custom/config', + }); + }); +}); diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index 33edb836a90de7..cb16692eef1d54 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -37,6 +37,11 @@ export interface IAgentNetworkFilterService { */ isUriAllowed(uri: URI): boolean; + /** + * Returns whether network filtering is currently enabled. + */ + isEnabled(): boolean; + /** * Formats an error message for a blocked URI based on the current filter configuration. * @param uri The URI that was blocked. @@ -90,7 +95,7 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo isUriAllowed(uri: URI): boolean { // When domain filtering is inactive, allow all requests. - if (!this.shouldFilter()) { + if (!this.isEnabled()) { return true; } @@ -112,9 +117,8 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return result; } - // Determines whether network filtering should be applied for a given request - // based on the global network filter setting. - private shouldFilter(): boolean { + + isEnabled(): boolean { return this.networkFilterEnabled; } diff --git a/src/vs/platform/theme/test/common/testThemeService.ts b/src/vs/platform/theme/test/common/testThemeService.ts index 817d76b3e435fe..f7486a084a8697 100644 --- a/src/vs/platform/theme/test/common/testThemeService.ts +++ b/src/vs/platform/theme/test/common/testThemeService.ts @@ -93,6 +93,11 @@ export class TestThemeService implements IThemeService { return this._fileIconTheme; } + setFileIconTheme(theme: IFileIconTheme): void { + this._fileIconTheme = theme; + this._onFileIconThemeChange.fire(theme); + } + public get onDidFileIconThemeChange(): Event { return this._onFileIconThemeChange.event; } diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index a1dfec3686a188..7f8e66f3b870e2 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -140,6 +140,7 @@ suite('WebPageLoader', () => { const agentNetworkFilterService: IAgentNetworkFilterService = { _serviceBrand: undefined, onDidChange: Event.None, + isEnabled: () => true, isUriAllowed: isDomainAllowed ?? (() => true), formatError: (u) => `Access to ${u.authority} is blocked by network domain policy.`, }; diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 9a562e6a26ebb8..cd17ad8eec366d 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -27,10 +27,6 @@ height: calc(100% - var(--vscode-agents-layout-floatingPanelGap)); } -.monaco-workbench.agent-sessions-workbench.nosidebar > .monaco-grid-view { - margin-left: var(--vscode-agents-layout-floatingPanelGap); -} - .monaco-workbench.agent-sessions-workbench.shell-gradient-background { position: relative; isolation: isolate; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 228d91f26e6708..fa0dea88f8d383 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -1876,9 +1876,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic protected _layoutGrid(): void { const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; - // Keep in sync with the desktop grid margin in workbench.css. + // Keep the desktop grid margin stable when sidebar visibility changes. const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; - const gridGutterW = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP + (this.partVisibility.sidebar ? 0 : AGENTS_FLOATING_PANEL_GAP); + const gridGutterW = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP; const gridGutterH = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP; this.workbenchGrid.layout( this._mainContainerDimension.width - gridGutterW, diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index addcd03f330ed2..6d1edb789dcb4b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -266,8 +266,8 @@ class CollapseAllSessionChangesDiffsAction extends Action2 { icon: Codicon.collapseAll, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '1_diff', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/1_diff', order: 10, when: ContextKeyExpr.and( singlePaneChangesEditorTitleVisible, @@ -296,8 +296,8 @@ class ExpandAllSessionChangesDiffsAction extends Action2 { icon: Codicon.expandAll, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '1_diff', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/1_diff', order: 10, when: ContextKeyExpr.and( singlePaneChangesEditorActive, @@ -325,7 +325,7 @@ registerAction2(ExpandAllSessionChangesDiffsAction); // The action changes the preferred layout. Side by side still falls back to inline // when the editor is narrow, so the label must not promise an immediate layout. -MenuRegistry.appendMenuItem(Menus.SessionsEditorTitle, { +MenuRegistry.appendMenuItem(Menus.SessionsEditorHeaderLayout, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('alwaysShowInlineDiff', "Always Show Inline Diff"), @@ -333,7 +333,7 @@ MenuRegistry.appendMenuItem(Menus.SessionsEditorTitle, { icon: Codicon.diffSidebyside, toggled: SessionsDiffRenderSideBySideContext.negate(), }, - group: '1_diff', + group: 'secondary/1_diff', order: 20, when: singlePaneDiffEditorTitleVisible }); diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css index abab6370bdc2ad..e0ef7f03e2b8d4 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -82,7 +82,7 @@ * `.editor-group-header-primary-actions`). It remains fully interactive/clickable, so no * hover-background override is needed here -- it keeps the normal toolbar hover. */ .changes-diff-stats-action-rich { - flex: 1 1 auto; + flex: 1 0 auto; min-width: 0; } @@ -135,6 +135,18 @@ /* Branch Changes picker (label + chevron) styling, keyed off the item's own * marker class so its chevron stays compact and vertically centered in both the * classic internal changes-editor header and the single-pane editor-group header. */ +.changes-picker-action-rich { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; +} + +.changes-picker-action-rich > .monaco-dropdown, +.changes-picker-action-rich > .monaco-dropdown > .dropdown-label { + min-width: 0; + max-width: 100%; +} + .changes-picker-action-rich .action-label { display: inline-flex; align-items: center; @@ -145,10 +157,11 @@ } .changes-picker-action-rich .action-label > span:not(.codicon) { + flex: 1 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 0; } .changes-picker-action-rich .action-label > .codicon { diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 6e924a37405f41..c97a4759304d37 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -83,6 +83,7 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { @ICommandService private readonly commandService: ICommandService, @IChangesViewService private readonly changesViewService: IChangesViewService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, ) { } createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel { @@ -111,6 +112,14 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { } return undefined; } + + openDiffEditor(original: URI, modified: URI): void { + void this.editorService.openEditor({ + original: { resource: original }, + modified: { resource: modified }, + options: { pinned: true }, + }); + } } class SessionChangesResourceLabel extends Disposable implements IResourceLabel { @@ -436,9 +445,7 @@ export class SessionChangesEditor extends AbstractEditorWithViewState