diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cbe044a133d8b1..31f1b8320d9086 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,3 +18,12 @@ src/vs/workbench/services/extensions/common/extensionPoints.json @TylerLeonhardt # Adding entries here lets a new .js/.cjs/.mjs file land in the repo; # review is required to make sure TypeScript is not a better choice. .eslint-allowed-javascript-files @alexr00 @alexdima @sbatten @TylerLeonhardt + +# Agents Window architecture specifications and their routing policy. +# These files describe stable contracts and should not change for routine fixes. +/src/vs/sessions/*.md @sandy081 +/src/vs/sessions/contrib/layout/browser/*.md @sandy081 +/src/vs/sessions/contrib/providers/*/*.md @sandy081 +/.github/instructions/sessions.instructions.md @sandy081 +/.github/skills/sessions/SKILL.md @sandy081 +/.github/skills/chat-customizations-editor/SKILL.md @sandy081 diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index c657361ef1e693..8c8b184b6c15b3 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -11,13 +11,15 @@ Split-view management pane for AI customization items across workspace, user, ex ## Spec -**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after. +**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — read for ownership and interface +contracts. Update it only when those contracts change; behavior and regressions +belong in focused tests. ## Key Folders | Folder | What | |--------|------| -| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers | +| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `ICustomizationItemProvider` — shared interfaces | | `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration | | `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) | | `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar | @@ -26,16 +28,15 @@ When changing harness descriptor interfaces or factory functions, verify both co ## Key Interfaces -- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. +- **`IHarnessDescriptor`** — drives harness behavior declaratively (hidden sections, button overrides, item providers, agent gating). See spec for the stable ownership contract. - **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). -- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. -- **`IExternalCustomizationItemProvider`** / **`IExternalCustomizationItem`** — internal interfaces (in `customizationHarnessService.ts`) for extension-contributed providers that supply items directly. These mirror the proposed extension API types. +- **`ICustomizationItemProvider`** / **`ICustomizationItem`** — internal interfaces (in `customizationHarnessService.ts`) for extension-contributed providers that supply items directly. These mirror the proposed extension API types. Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. ## Extension API (`chatSessionCustomizationProvider`) -The proposed API in `src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts` lets extensions register customization providers. Changes to `IExternalCustomizationItem` or `IExternalCustomizationItemProvider` must be kept in sync across the full chain: +The proposed API in `src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts` lets extensions register customization providers. Changes to `ICustomizationItem` or `ICustomizationItemProvider` must be kept in sync across the full chain: | Layer | File | Type | |-------|------|------| @@ -43,9 +44,9 @@ The proposed API in `src/vscode-dts/vscode.proposed.chatSessionCustomizationProv | IPC DTO | `extHost.protocol.ts` | `IChatSessionCustomizationItemDto` | | ExtHost mapping | `extHostChatAgents2.ts` | `$provideChatSessionCustomizations()` | | MainThread mapping | `mainThreadChatAgents2.ts` | `provideChatSessionCustomizations` callback | -| Internal interface | `customizationHarnessService.ts` | `IExternalCustomizationItem` | +| Internal interface | `customizationHarnessService.ts` | `ICustomizationItem` | -When adding fields to `IExternalCustomizationItem`, update all five layers. The proposed API `.d.ts` is additive-only (new optional fields are backward-compatible and do not require a version bump). +When adding fields to `ICustomizationItem`, update all five layers. The proposed API `.d.ts` is additive-only (new optional fields are backward-compatible and do not require a version bump). ## Testing diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index b69ae015e51bc5..ff830579601029 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -222,6 +222,32 @@ delivery slot for the managed value. | `projectManagedSettings(values, definitions, onWarn?)` | Keeps only declared keys whose runtime value **matches the declared type**. Undeclared keys and type mismatches are **dropped (validated, never coerced)**, with an optional warning. | | `pickManagedSettings(nativeMdm, server, file)` | Merges the channels **per key** by precedence (native MDM → server → file): the highest-precedence channel that sets a key wins, lower channels fill in keys the higher ones leave unset, and every contribution is recorded for provenance. **The extension point when adding a new channel** — extend the `ManagedSettingsChannel` union, the `MANAGED_SETTINGS_CHANNELS` order, and this function together. | | `managedSettingValue(key)` | Builds the standard pass-through `value` callback `policyData => policyData.managedSettings?.[key]`. Use for the common "lock to the managed value, else fall through" case (see [Declaring a managed setting](#declaring-a-managed-setting-on-a-policy)). | +| `thirdPartyAgentEnabledValue(policyData)` | Shared `value` callback for the third-party harness policies (see [Governance presence](#governance-presence-disables-the-third-party-harnesses)). | + +### Governance presence disables the third-party harnesses + +`IPolicyData.managedSettingsActive` is `true` when **any** channel supplies **any** managed +setting — i.e. the user is governed at all, independent of which keys were set. It is set in +`AccountPolicyService.getPolicyData` from `pickManagedSettings(...).activeSources`, and unlike +`IPolicyData.managedSettings` it is **not** projected onto the keys VS Code declares, so it also +reflects runtime-owned keys VS Code never reads. + +The `Claude3PIntegration` and `Codex3PIntegration` policies both use +`thirdPartyAgentEnabledValue`, which forces its setting to `false` when the account disables +chat preview features **or** when `managedSettingsActive` is `true`. Rationale: managed settings +are composed and enforced by the Copilot runtime and never reach the Claude or Codex harnesses, +so leaving those harnesses available would hand a governed user an ungoverned path around every +control the enterprise set. This mirrors the runtime-owned `sandbox.enabled` floor retiring the +local harness (`IAgentHostEnablementService.managedSandboxEnforced`). + +Invariants: + +- The rule keys off **presence**, not a value, so the policies deliberately declare **no** + `managedSettings` keys — they must not be added to the native MDM watcher schema. +- `AccountPolicyService.resolvePolicyValue` probes for this presence dependence (re-evaluating + the callback with `managedSettingsActive: false`) so **Developer: Policy Diagnostics** + attributes the value to the governing channel rather than to the account. +- The `value` callback stays pure and deterministic — attribution evaluates it more than once. ### Normalization: the structured-key descriptor table diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index b56def2a37d8bf..af03be7f26386b 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -72,9 +72,26 @@ that preserve these boundaries: - Shared workbench changes represent shared capability, not Sessions-specific policy. -Update a specification when its architecture or durable behavior changes. Do not -add implementation chronology, rejected approaches, or bug narratives to a -specification. +### Specification edit gate + +Bug fixes do not update specifications when they restore an existing contract. +Before editing an authoritative specification, identify all three: + +1. the existing ownership, interface, lifecycle, state-machine, persistence, or + cross-component contract that intentionally changes; +2. the implementation surfaces affected by that contract change; +3. why a regression test and a brief code comment cannot fully represent it. + +If any answer is missing, leave the specification unchanged. Put concrete +behavior in a focused test, keep a non-obvious implementation constraint beside +the owning code, and preserve investigation history in the issue or pull +request. + +Update a specification only when component ownership, an interface or lifecycle +contract, a state machine, persistence, or a cross-component invariant changes. +Do not update specifications for styling, copy, action placement, telemetry +fields, settings defaults, implementation algorithms, or individual bug fixes. +Those details belong in code and focused tests. ## 5. Validate proportionally diff --git a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml index d325f16a55301f..2f1423a7d1f822 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -90,7 +90,6 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - az extension add --name azure-devops --upgrade --only-show-errors az artifacts universal download \ --organization "https://dev.azure.com/monacotools" \ --project "Monaco" \ diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 9c8d448b141dde..435fb9adf8e4ac 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -106,6 +106,10 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + - task: Docker@1 inputs: azureSubscriptionEndpoint: vscode @@ -135,7 +139,6 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - az extension add --name azure-devops --upgrade --only-show-errors az artifacts universal download \ --organization "https://dev.azure.com/monacotools" \ --project "Monaco" \ @@ -174,6 +177,10 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/common/disableFoundryLocalInstall.ts b/build/azure-pipelines/common/disableFoundryLocalInstall.ts index 501f52b1a2d859..f63cdaeea0aaad 100644 --- a/build/azure-pipelines/common/disableFoundryLocalInstall.ts +++ b/build/azure-pipelines/common/disableFoundryLocalInstall.ts @@ -3,22 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; -import * as path from 'path'; +import { disableFoundryLocalInstall } from './foundryLocalInstall.ts'; -const packageJsonPath = path.resolve(import.meta.dirname, '../../..', 'package.json'); -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { - dependencies?: Record; - allowScripts?: Record; -}; -const allowScripts = packageJson.allowScripts; -const foundryLocalVersion = packageJson.dependencies?.['foundry-local-sdk']; -const foundryLocalKey = foundryLocalVersion ? `foundry-local-sdk@${foundryLocalVersion}` : undefined; - -if (!allowScripts || !foundryLocalKey || allowScripts[foundryLocalKey] !== true) { - throw new Error('Expected an approved, pinned foundry-local-sdk install script in package.json'); -} - -allowScripts[foundryLocalKey] = false; -fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, undefined, 2)}\n`); -console.log(`Disabled ${foundryLocalKey} install script for this CI job`); +disableFoundryLocalInstall(); diff --git a/build/azure-pipelines/common/foundry-local.yml b/build/azure-pipelines/common/foundry-local.yml new file mode 100644 index 00000000000000..d40b360b8f33dc --- /dev/null +++ b/build/azure-pipelines/common/foundry-local.yml @@ -0,0 +1,18 @@ +parameters: + - name: phase + type: string + values: + - prepare + - install + +steps: + - ${{ if eq(parameters.phase, 'prepare') }}: + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication + + - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts + displayName: Disable Foundry Local Native Install + + - ${{ if eq(parameters.phase, 'install') }}: + - script: node build/azure-pipelines/common/foundryLocalInstall.ts + displayName: Install Foundry Local Native Dependencies diff --git a/build/azure-pipelines/common/foundryLocalInstall.ts b/build/azure-pipelines/common/foundryLocalInstall.ts new file mode 100644 index 00000000000000..ff80bc58bf5020 --- /dev/null +++ b/build/azure-pipelines/common/foundryLocalInstall.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fetchCoreLibraries, getStandardArtifacts, type IFoundryDependencyVersions, requiredCoreLibraryNames, supportsCoreLibraryTarget, VSCODE_NUGET_FEED } from '../../dictation-runtime/nuget.ts'; + +const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); +const packageName = 'foundry-local-sdk'; +const credentialTokenEnvironmentVariable = 'VSS_NUGET_ACCESSTOKEN'; +const expectedInstallerUtilsHash = '0831c932b10389283e805f88a204b0f6a5a8053f2ee520e56a0f0adf1352aa8b'; + +type RootPackageJson = { + dependencies?: Record; + allowScripts?: Record; +}; + +type FoundryPackageJson = { + version?: string; + scripts?: Record; +}; + +function readJson(filePath: string): T { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; +} + +function getPinnedPackage(root: string): { packageJsonPath: string; packageJson: RootPackageJson; allowScripts: Record; allowScriptsKey: string } { + const packageJsonPath = path.join(root, 'package.json'); + const packageJson = readJson(packageJsonPath); + const allowScripts = packageJson.allowScripts; + const version = packageJson.dependencies?.[packageName]; + const allowScriptsKey = version ? `${packageName}@${version}` : undefined; + + if (!allowScripts || !version || !allowScriptsKey || allowScripts[allowScriptsKey] !== true) { + throw new Error(`Expected an approved, pinned ${packageName} install script in package.json`); + } + + return { packageJsonPath, packageJson, allowScripts, allowScriptsKey }; +} + +export function disableFoundryLocalInstall(root = repositoryRoot): void { + const { packageJsonPath, packageJson, allowScripts, allowScriptsKey } = getPinnedPackage(root); + allowScripts[allowScriptsKey] = false; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, undefined, 2)}\n`); + console.log(`Disabled ${allowScriptsKey} install script for this CI job`); +} + +function validateInstallerUtils(installerUtilsPath: string): void { + const contents = fs.readFileSync(installerUtilsPath); + const actualHash = createHash('sha256').update(contents).digest('hex'); + + if (actualHash !== expectedInstallerUtilsHash) { + throw new Error(`Unexpected ${packageName} installer utility hash ${actualHash}`); + } +} + +function runLifecycleScript(packageRoot: string, relativeScriptPath: string): void { + execFileSync(process.execPath, [path.join(packageRoot, relativeScriptPath)], { + cwd: packageRoot, + stdio: 'inherit' + }); +} + +export async function installFoundryLocal(root = repositoryRoot): Promise { + if (!process.env[credentialTokenEnvironmentVariable]) { + throw new Error(`${credentialTokenEnvironmentVariable} was not set by NuGetAuthenticate`); + } + + const rootPackageJson = readJson(path.join(root, 'package.json')); + const version = rootPackageJson.dependencies?.[packageName]; + const allowScriptsKey = version ? `${packageName}@${version}` : undefined; + if (!version || !allowScriptsKey || rootPackageJson.allowScripts?.[allowScriptsKey] !== false) { + throw new Error(`Expected the pinned ${packageName} install script to be disabled before installation`); + } + + const packageRoot = path.join(root, 'node_modules', packageName); + const packageJson = readJson(path.join(packageRoot, 'package.json')); + if (packageJson.version !== version) { + throw new Error(`Expected ${packageName}@${version}, found ${packageJson.version ?? 'an unknown version'}`); + } + if (packageJson.scripts?.preinstall !== 'node script/preinstall.cjs' || packageJson.scripts.install !== 'node script/install-standard.cjs') { + throw new Error(`Unexpected ${packageName}@${version} lifecycle scripts`); + } + + validateInstallerUtils(path.join(packageRoot, 'script', 'install-utils.cjs')); + runLifecycleScript(packageRoot, 'script/preinstall.cjs'); + + const target = `${process.platform}-${process.arch}`; + if (!supportsCoreLibraryTarget(target)) { + console.warn(`[foundry-local] Unsupported platform: ${target}. Skipping.`); + return; + } + + const dependencies = readJson(path.join(packageRoot, 'deps_versions.json')); + const artifacts = getStandardArtifacts(target, dependencies); + const binDir = path.join(packageRoot, 'foundry-local-core', target); + await fetchCoreLibraries(target, artifacts, binDir, { feeds: [VSCODE_NUGET_FEED], skipIfPresent: true }); + + const missingFiles = requiredCoreLibraryNames(target).filter(file => !fs.existsSync(path.join(binDir, file))); + if (missingFiles.length > 0) { + throw new Error(`[foundry-local] Missing required native libraries for ${target}: ${missingFiles.join(', ')}`); + } + + const coreVersion = dependencies['foundry-local-core'].nuget; + const platformPackageJson = { + name: `@foundry-local-core/${target}`, + version: coreVersion, + description: `Native binaries for Foundry Local SDK (${target})`, + private: true, + }; + fs.writeFileSync(path.join(binDir, 'package.json'), JSON.stringify(platformPackageJson, undefined, 2)); + console.log('[foundry-local] Installation complete.'); +} + +if (import.meta.filename === process.argv[1]) { + await installFoundryLocal(); +} diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index 3a37fce430779e..3394ff0e82456d 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -102,6 +102,9 @@ jobs: displayName: Create Crash Dumps Directory - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: + - task: NuGetAuthenticate@1 + displayName: Setup NuGet Authentication + - script: | @echo off setlocal enabledelayedexpansion @@ -117,7 +120,7 @@ jobs: if exist "!SDK_ROOT!" rmdir /s /q "!SDK_ROOT!" set "SDK_PACKAGE=$(Agent.TempDirectory)\windows-sdk-build-tools.nupkg" - curl.exe -fsSL --retry 5 --retry-delay 2 --retry-all-errors "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/!PACKAGE_VERSION!/microsoft.windows.sdk.buildtools.!PACKAGE_VERSION!.nupkg" -o "!SDK_PACKAGE!" + curl.exe -fsSL --retry 5 --retry-delay 2 --retry-all-errors -u "vscode:%VSS_NUGET_ACCESSTOKEN%" "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/flat2/microsoft.windows.sdk.buildtools/!PACKAGE_VERSION!/microsoft.windows.sdk.buildtools.!PACKAGE_VERSION!.nupkg" -o "!SDK_PACKAGE!" set "ACTUAL_HASH=" for /f "skip=1" %%A in ('certutil -hashfile "!SDK_PACKAGE!" SHA256') do if not defined ACTUAL_HASH set "ACTUAL_HASH=%%A" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 851d6c584dfc1a..15630252929ff2 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -24,7 +24,7 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true) }}: + - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts displayName: Disable Foundry Local Native Install @@ -80,6 +80,11 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: prepare + - task: PipAuthenticate@1 inputs: artifactFeeds: Monaco/vscode @@ -112,6 +117,11 @@ steps: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 8f40a0991cb526..e5b93a11fad3a5 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -32,7 +32,7 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true) }}: + - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts displayName: Disable Foundry Local Native Install @@ -100,6 +100,11 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: prepare + - script: | set -e @@ -159,6 +164,11 @@ steps: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index de08f085ca859b..18a439903b7db9 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -290,7 +290,7 @@ extends: ubuntu-2004-arm64: image: onebranch.azurecr.io/linux/ubuntu-2004-arm64:latest settings: - networkIsolationPolicy: Permissive,CFSClean2,CFSClean3 + networkIsolationPolicy: Permissive,CFSClean,CFSClean2,CFSClean3 stages: - stage: Quality diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 6eaa5d5ae1deb4..2282b69ef03dc8 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -56,6 +56,10 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ./common/foundry-local.yml@self + parameters: + phase: prepare + - script: | set -e @@ -105,6 +109,10 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ./common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/product-smoke-flaky.yml b/build/azure-pipelines/product-smoke-flaky.yml index dfcc3fc2c13e11..ce6473e5698119 100644 --- a/build/azure-pipelines/product-smoke-flaky.yml +++ b/build/azure-pipelines/product-smoke-flaky.yml @@ -175,7 +175,7 @@ extends: sourceAnalysisPool: 1es-windows-2022-x64 createAdoIssuesForJustificationsForDisablement: false settings: - networkIsolationPolicy: Permissive,CFSClean2,CFSClean3 + networkIsolationPolicy: Permissive,CFSClean,CFSClean2,CFSClean3 stages: # The per-OS compile template unconditionally waits (up to 30 min) for a # `Copilot` job to publish the `copilot_vsix` artifact, which is mixed into diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 36fefb59585802..131f2502b1d202 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -68,6 +68,10 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + - script: | set -e ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get update @@ -93,6 +97,10 @@ jobs: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - template: ../common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 65c3423d648005..9d49b70a9a75cc 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -48,6 +48,10 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - template: ../common/foundry-local.yml@self + parameters: + phase: prepare + - pwsh: | $includes = @' { @@ -92,6 +96,10 @@ steps: retryCountOnTaskFailure: 5 displayName: Install dependencies + - template: ../common/foundry-local.yml@self + parameters: + phase: install + - script: node build/azure-pipelines/distro/mixin-npm.ts displayName: Mixin distro node modules diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 043daa1e0345b8..721c94a8dac923 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -26,7 +26,7 @@ steps: versionSource: fromFile versionFilePath: .nvmrc - - ${{ if eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true) }}: + - ${{ if or(eq(parameters.VSCODE_CIBUILD, true), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, true)) }}: - script: node build/azure-pipelines/common/disableFoundryLocalInstall.ts displayName: Disable Foundry Local Native Install @@ -86,6 +86,11 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Authentication + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: prepare + - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" @@ -100,6 +105,11 @@ steps: displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + - ${{ if and(eq(parameters.VSCODE_CIBUILD, false), eq(parameters.VSCODE_SKIP_FOUNDRY_LOCAL_INSTALL, false)) }}: + - template: ../../common/foundry-local.yml@self + parameters: + phase: install + - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts displayName: Verify native optional dependency binaries diff --git a/build/dictation-runtime/nuget.ts b/build/dictation-runtime/nuget.ts index 98ecc4f6d1c235..91c2025c9f811d 100644 --- a/build/dictation-runtime/nuget.ts +++ b/build/dictation-runtime/nuget.ts @@ -23,6 +23,7 @@ */ import { createRequire } from 'module'; +import { Buffer } from 'buffer'; import * as fs from 'fs'; import * as https from 'https'; import * as os from 'os'; @@ -30,6 +31,9 @@ import * as path from 'path'; import { SDK_PACKAGE_NAME } from './common.ts'; const SCRIPT = 'nuget.ts'; +const VSCODE_FEED_PREFIX = 'https://pkgs.dev.azure.com/monacotools/'; +const VSS_NUGET_ACCESSTOKEN = 'VSS_NUGET_ACCESSTOKEN'; +export const VSCODE_NUGET_FEED = 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/nuget/v3/index.json'; /** * `adm-zip`, resolved through `foundry-local-sdk`'s own dependency tree (it is a @@ -42,17 +46,26 @@ function loadAdmZip(): any { return fromSdk('adm-zip'); } -/** - * NuGet feeds tried in order, matching `foundry-local-sdk`'s installer: the - * stable nuget.org feed first, then the public ORT-Nightly Azure DevOps feed - * (where pre-release Foundry Local Core / ORT / ORT-GenAI builds live before - * they reach nuget.org). - */ +/** The authenticated VS Code NuGet feed used for native runtime packages. */ const FEEDS: readonly string[] = [ - 'https://api.nuget.org/v3/index.json', - 'https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json', + VSCODE_NUGET_FEED, ]; +function getRequestOptions(url: string): https.RequestOptions { + if (!url.startsWith(VSCODE_FEED_PREFIX)) { + return {}; + } + const token = process.env[VSS_NUGET_ACCESSTOKEN]; + if (!token) { + throw new Error(`${VSS_NUGET_ACCESSTOKEN} is required to access the VS Code NuGet feed.`); + } + return { + headers: { + Authorization: `Basic ${Buffer.from(`vscode:${token}`).toString('base64')}`, + }, + }; +} + /** The NuGet Runtime IDentifier for each supported runtime target. */ const RID_BY_TARGET: Readonly> = { 'win32-x64': 'win-x64', @@ -72,18 +85,54 @@ export interface INugetArtifact { readonly version: string; } +export interface IFoundryDependencyVersions { + readonly 'foundry-local-core': { readonly nuget: string }; + readonly onnxruntime: { readonly version: string }; + readonly 'onnxruntime-genai': { readonly version: string }; +} + +export interface IFetchCoreLibrariesOptions { + readonly feeds?: readonly string[]; + readonly skipIfPresent?: boolean; +} + +export function supportsCoreLibraryTarget(target: string): boolean { + return Object.hasOwn(RID_BY_TARGET, target); +} + +export function getStandardArtifacts(target: string, dependencies: IFoundryDependencyVersions): readonly INugetArtifact[] { + const ortPackageName = target === 'linux-x64' ? 'Microsoft.ML.OnnxRuntime.Gpu.Linux' : 'Microsoft.ML.OnnxRuntime.Foundry'; + return [ + { name: 'Microsoft.AI.Foundry.Local.Core', version: dependencies['foundry-local-core'].nuget }, + { name: ortPackageName, version: dependencies.onnxruntime.version }, + { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: dependencies['onnxruntime-genai'].version }, + ]; +} + +export function requiredCoreLibraryNames(target: string): readonly string[] { + const isWin = target.startsWith('win32-'); + const ext = isWin ? '.dll' : target.startsWith('darwin-') ? '.dylib' : '.so'; + const prefix = isWin ? '' : 'lib'; + return [ + `Microsoft.AI.Foundry.Local.Core${ext}`, + `${prefix}onnxruntime${ext}`, + `${prefix}onnxruntime-genai${ext}`, + ]; +} + /** * Download each `artifact` `.nupkg` for `target`'s RID and extract its native * shared libraries into `binDir`. Throws if a package can't be fetched from any * feed; callers verify the resulting library set separately. */ -export async function fetchCoreLibraries(target: string, artifacts: readonly INugetArtifact[], binDir: string): Promise { +export async function fetchCoreLibraries(target: string, artifacts: readonly INugetArtifact[], binDir: string, options?: IFetchCoreLibrariesOptions): Promise { const rid = RID_BY_TARGET[target]; if (!rid) { throw new Error(`[${SCRIPT}] No NuGet RID mapping for target '${target}'.`); } const ext = libExt(target); const AdmZip = loadAdmZip(); + const feeds = options?.feeds ?? FEEDS; fs.mkdirSync(binDir, { recursive: true }); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dictation-nuget-')); @@ -91,7 +140,7 @@ export async function fetchCoreLibraries(target: string, artifacts: readonly INu try { console.log(`[${SCRIPT}] Fetching native libraries for RID ${rid} (target ${target})...`); for (const artifact of artifacts) { - await installPackage(artifact, rid, ext, tempDir, binDir, AdmZip, serviceIndexCache); + await installPackage(artifact, target, rid, ext, tempDir, binDir, AdmZip, serviceIndexCache, feeds, options?.skipIfPresent ?? false); } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -100,16 +149,27 @@ export async function fetchCoreLibraries(target: string, artifacts: readonly INu async function installPackage( artifact: INugetArtifact, + target: string, rid: string, ext: string, tempDir: string, binDir: string, AdmZip: any, serviceIndexCache: Map, + feeds: readonly string[], + skipIfPresent: boolean, ): Promise { + if (skipIfPresent) { + const expectedFile = expectedCoreLibraryName(target, artifact.name); + if (expectedFile && fs.existsSync(path.join(binDir, expectedFile))) { + console.log(`[${SCRIPT}] ${artifact.name}: already present, skipping download.`); + return; + } + } + let lastError: unknown; - for (let i = 0; i < FEEDS.length; i++) { - const feedUrl = FEEDS[i]; + for (let i = 0; i < feeds.length; i++) { + const feedUrl = feeds[i]; const feedHost = new URL(feedUrl).host; try { const baseAddress = await getBaseAddress(feedUrl, serviceIndexCache); @@ -134,13 +194,27 @@ async function installPackage( } catch (err) { lastError = err; const reason = err instanceof Error ? err.message : String(err); - if (i < FEEDS.length - 1) { + if (i < feeds.length - 1) { console.warn(`[${SCRIPT}] ${artifact.name} ${artifact.version}: download from ${feedHost} failed (${reason}); trying next feed...`); } } } - const feeds = FEEDS.map(f => new URL(f).host).join(', '); - throw new Error(`[${SCRIPT}] Failed to download ${artifact.name} ${artifact.version} from any feed (${feeds}): ${lastError instanceof Error ? lastError.message : lastError}`); + const feedHosts = feeds.map(feed => new URL(feed).host).join(', '); + throw new Error(`[${SCRIPT}] Failed to download ${artifact.name} ${artifact.version} from any feed (${feedHosts}): ${lastError instanceof Error ? lastError.message : lastError}`); +} + +function expectedCoreLibraryName(target: string, packageName: string): string | undefined { + const [foundryCore, onnxRuntime, onnxRuntimeGenAI] = requiredCoreLibraryNames(target); + if (packageName.includes('Foundry.Local.Core')) { + return foundryCore; + } + if (packageName.includes('OnnxRuntimeGenAI')) { + return onnxRuntimeGenAI; + } + if (packageName.includes('OnnxRuntime')) { + return onnxRuntime; + } + return undefined; } /** @@ -204,7 +278,7 @@ function downloadToFile(url: string, dest: string): Promise { function followRedirects(url: string, onOk: (res: import('http').IncomingMessage) => Promise): Promise { return new Promise((resolve, reject) => { const request = (currentUrl: string, redirectsLeft: number): void => { - https.get(currentUrl, res => { + https.get(currentUrl, getRequestOptions(currentUrl), res => { const status = res.statusCode ?? 0; if (status >= 300 && status < 400 && res.headers.location) { res.resume(); diff --git a/build/dictation-runtime/package.ts b/build/dictation-runtime/package.ts index e4a624efab8f91..22ecfcab261608 100644 --- a/build/dictation-runtime/package.ts +++ b/build/dictation-runtime/package.ts @@ -11,7 +11,7 @@ * * The library form is what `produce.ts` calls during the per-platform * "Dictation runtime: build + upload" pipeline step; the CLI form is for local - * one-off builds. + * one-off builds and requires `VSS_NUGET_ACCESSTOKEN` for the VS Code NuGet feed. * * The addon is copied from the pinned `foundry-local-sdk` package's `prebuilds/` * (which ships every target), and the core libraries are fetched from NuGet for @@ -33,7 +33,7 @@ import * as os from 'os'; import * as path from 'path'; import * as tar from 'tar'; import { getRuntimeVersion, parseFlags, SDK_PACKAGE_NAME, sha256OfFile, SUPPORTED_TARGETS } from './common.ts'; -import { fetchCoreLibraries } from './nuget.ts'; +import { fetchCoreLibraries, getStandardArtifacts, type IFoundryDependencyVersions, requiredCoreLibraryNames } from './nuget.ts'; const SCRIPT = 'package.ts'; @@ -108,22 +108,8 @@ async function stageAddon(stagingDir: string, target: string): Promise { * Runtime package. Host-independent — `target` need not match the build host. */ async function stageCoreLibraries(stagingDir: string, target: string): Promise { - const deps = sdkRequire(`${SDK_PACKAGE_NAME}/deps_versions.json`) as { - 'foundry-local-core': { nuget: string }; - onnxruntime: { version: string }; - 'onnxruntime-genai': { version: string }; - }; - - // Microsoft.ML.OnnxRuntime.Gpu.Linux only ships x86_64 native binaries, so - // linux-arm64 (and every non-linux-x64 target) uses the cross-platform - // Foundry ORT package. Mirrors `ensureCoreLibraries` in the runtime. - const ortPackageName = target === 'linux-x64' ? 'Microsoft.ML.OnnxRuntime.Gpu.Linux' : 'Microsoft.ML.OnnxRuntime.Foundry'; - - const artifacts = [ - { name: 'Microsoft.AI.Foundry.Local.Core', version: deps['foundry-local-core'].nuget }, - { name: ortPackageName, version: deps.onnxruntime.version }, - { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: deps['onnxruntime-genai'].version }, - ]; + const dependencies = sdkRequire(`${SDK_PACKAGE_NAME}/deps_versions.json`) as IFoundryDependencyVersions; + const artifacts = getStandardArtifacts(target, dependencies); const coreDir = path.join(stagingDir, 'foundry-local-core', target); await fetchCoreLibraries(target, artifacts, coreDir); @@ -135,19 +121,6 @@ async function stageCoreLibraries(stagingDir: string, target: string): Promise; + private readonly tokenValue: FetchedValue; + get token() { - void this.tokenRefetcher.trigger(() => this.updateCachedToken()); - return this._token; + void this.tokenRefetcher.trigger(() => this.tokenValue.resolve()).catch(() => { + // Foreground getToken calls surface the cached error. + }); + return this.tokenValue.value; } constructor( @@ -35,8 +39,15 @@ export class CopilotTokenManagerImpl extends Disposable implements ICompletionsC ) { super(); - this.updateCachedToken(); - this._register(this.authenticationService.onDidCopilotTokenChange(() => this.updateCachedToken())); + this.tokenRefetcher = this._register(new ThrottledDelayer(5_000)); + this.tokenValue = this._register(new FetchedValue({ + fetch: () => this.authenticationService.getCopilotToken(), + isStale: () => true, + getRetryAfterMs: () => 5_000, + })); + + this.resolveInBackground(); + this._register(this.authenticationService.onDidCopilotTokenChange(() => this.resolveInBackground(true))); } /** @@ -54,15 +65,17 @@ export class CopilotTokenManagerImpl extends Disposable implements ICompletionsC } async getToken(): Promise { - return this.updateCachedToken(); + return this.tokenValue.resolve(); } - private async updateCachedToken(): Promise { - this._token = await this.authenticationService.getCopilotToken(); - return this._token; + private resolveInBackground(force?: boolean): void { + void this.tokenValue.resolve(force).catch(() => { + // Foreground getToken calls surface the cached error. + }); } resetToken(httpError?: number): void { + this.tokenValue.invalidate(); this.authenticationService.resetCopilotToken(); } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/auth/test/copilotTokenManager.spec.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/auth/test/copilotTokenManager.spec.ts new file mode 100644 index 00000000000000..320c699f456a0c --- /dev/null +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/auth/test/copilotTokenManager.spec.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IAuthenticationService } from '../../../../../../../platform/authentication/common/authentication'; +import { StaticGitHubAuthenticationService } from '../../../../../../../platform/authentication/common/staticGitHubAuthenticationService'; +import { CopilotToken, createTestExtendedTokenInfo } from '../../../../../../../platform/authentication/common/copilotToken'; +import { ICopilotTokenManager } from '../../../../../../../platform/authentication/common/copilotTokenManager'; +import { ICopilotTokenStore } from '../../../../../../../platform/authentication/common/copilotTokenStore'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../../../../../platform/log/common/logService'; +import { createPlatformServices, ITestingServicesAccessor } from '../../../../../../../platform/test/node/services'; +import { FetchBlockedError } from '../../../../../../../shared-fetch-utils/common/fetchTypes'; +import { Event } from '../../../../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../../../../util/vs/base/common/lifecycle'; +import { CopilotTokenManagerImpl } from '../copilotTokenManager'; + +describe('CopilotTokenManagerImpl', () => { + let accessor: ITestingServicesAccessor; + let disposables: DisposableStore; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(100); + disposables = new DisposableStore(); + accessor = disposables.add(createPlatformServices().createTestingAccessor()); + }); + + afterEach(() => { + disposables.dispose(); + vi.useRealTimers(); + }); + + it('caches ordinary failures for five seconds', async () => { + const tokenManager = new FailingCopilotTokenManager(() => new Error('network failure')); + const manager = createManager(tokenManager); + + await expect(manager.getToken()).rejects.toThrow('network failure'); + await expect(manager.getToken()).rejects.toThrow('network failure'); + expect(tokenManager.calls).toBe(1); + + vi.advanceTimersByTime(4_999); + await expect(manager.getToken()).rejects.toThrow('network failure'); + expect(tokenManager.calls).toBe(1); + + vi.advanceTimersByTime(1); + await expect(manager.getToken()).rejects.toThrow('network failure'); + expect(tokenManager.calls).toBe(2); + }); + + it('prefers a server retry delay over the fallback cooldown', async () => { + const tokenManager = new FailingCopilotTokenManager(() => new FetchBlockedError('rate limited', 30_000)); + const manager = createManager(tokenManager); + + await expect(manager.getToken()).rejects.toThrow('rate limited'); + vi.advanceTimersByTime(5_000); + await expect(manager.getToken()).rejects.toThrow('rate limited'); + expect(tokenManager.calls).toBe(1); + + vi.advanceTimersByTime(25_000); + await expect(manager.getToken()).rejects.toThrow('rate limited'); + expect(tokenManager.calls).toBe(2); + }); + + it('foreground calls still fail after a successful token fetch', async () => { + const token = new CopilotToken(createTestExtendedTokenInfo({ token: 'tid=success' })); + const tokenManager = new ScriptedCopilotTokenManager([ + token, + new Error('signed out'), + ]); + const manager = createManager(tokenManager); + + await expect(manager.getToken()).resolves.toBe(token); + await expect(manager.getToken()).rejects.toThrow('signed out'); + await expect(manager.primeToken()).resolves.toBe(false); + + expect(manager.token).toBe(token); + expect(tokenManager.calls).toBe(2); + }); + + function createManager(tokenManager: ICopilotTokenManager): CopilotTokenManagerImpl { + const authenticationService: IAuthenticationService = disposables.add(new StaticGitHubAuthenticationService( + () => 'github-token', + accessor.get(ILogService), + accessor.get(ICopilotTokenStore), + tokenManager, + accessor.get(IConfigurationService), + )); + return disposables.add(new CopilotTokenManagerImpl(false, authenticationService)); + } +}); + +class FailingCopilotTokenManager implements ICopilotTokenManager { + declare readonly _serviceBrand: undefined; + readonly onDidCopilotTokenRefresh = Event.None; + calls = 0; + + constructor(private readonly createError: () => Error) { } + + async getCopilotToken(): Promise { + this.calls++; + throw this.createError(); + } + + resetCopilotToken(): void { } +} + +class ScriptedCopilotTokenManager implements ICopilotTokenManager { + declare readonly _serviceBrand: undefined; + readonly onDidCopilotTokenRefresh = Event.None; + calls = 0; + + constructor(private readonly results: Array) { } + + async getCopilotToken(): Promise { + this.calls++; + const result = this.results.shift(); + if (!result) { + throw new Error('No scripted token result'); + } + if (result instanceof Error) { + throw result; + } + return result; + } + + resetCopilotToken(): void { } +} diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index b882e3932019c5..da68766d1fc192 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { t } from '@vscode/l10n'; -import { realpath } from 'fs/promises'; import { homedir } from 'os'; -import * as path from 'path'; import type { LanguageModelChat, PreparedToolInvocation } from 'vscode'; import { ToolName } from '../common/toolNames'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; @@ -37,6 +35,7 @@ import { ServicesAccessor } from '../../../util/vs/platform/instantiation/common import { EndOfLine, Position, Range, TextEdit } from '../../../vscodeTypes'; import { IBuildPromptContext } from '../../prompt/common/intents'; import { formatUriForFileWidget } from '../common/toolUtils'; +import { resolveRealPathForNonexistent } from './toolUtils'; // Simplified Hunk type for the patch interface Hunk { @@ -803,46 +802,6 @@ export const enum ConfirmationCheckResult { OutsideWorkspace, } -/** - * Resolves the real path of `fsPath`, walking up the parent chain when the path - * (or its ancestors) does not yet exist on disk. This ensures that a symlink at - * any ancestor. - */ -async function resolveRealPathForNonexistent(fsPath: string): Promise { - try { - return await realpath(fsPath); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - - const tail: string[] = [path.basename(fsPath)]; - let current = path.dirname(fsPath); - while (true) { - const parent = path.dirname(current); - if (parent === current) { - // Reached the filesystem root without finding an existing ancestor. - // Don't attempt to resolve the root itself — on Windows, realpath('\\') - // normalizes to a drive letter (e.g. 'C:\\'), which would otherwise look - // like a redirect even though no symlink was involved. - return fsPath; - } - try { - const resolved = await realpath(current); - return path.join(resolved, ...tail); - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw e; - } - } - tail.unshift(path.basename(current)); - current = parent; - } -} - - /** * Returns a function that returns whether a URI is approved for editing without * further user confirmation. @@ -939,11 +898,11 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService, const toCheck = [normalizePath(uri)]; if (uri.scheme === Schemas.file) { try { - const linked = await resolveRealPathForNonexistent(uri.fsPath); - assertPathIsSafe(linked); + const linked = await resolveRealPathForNonexistent(uri); + assertPathIsSafe(linked.fsPath); - if (linked !== uri.fsPath) { - toCheck.push(URI.file(linked)); + if (!extUriBiasedIgnorePathCase.isEqual(linked, uri)) { + toCheck.push(linked); } } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EPERM') { diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 359d5995dcdec3..a234134dcf1dc7 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -35,7 +35,7 @@ import { ToolName } from '../common/toolNames'; import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; import { formatUriForFileWidget } from '../common/toolUtils'; import { getImageMimeType } from './imageToolUtils'; -import { assertFileNotContentExcluded, assertFileOkForTool, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; +import { assertFileNotContentExcluded, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; export const getReadFileV2Description = (orig: vscode.LanguageModelToolInformation): vscode.LanguageModelToolInformation => ({ name: ToolName.ReadFile, @@ -219,20 +219,30 @@ export class ReadFileTool implements ICopilotTool { throw new Error(`Cannot read image files with ${ToolName.ReadFile}. Use ${ToolName.ViewImage} instead.`); } + await this.instantiationService.invokeFunction( + accessor => assertFileNotContentExcluded(accessor, uri!) + ); + // Check if file is external (outside workspace, not open in editor, etc.) - const isExternal = await this.instantiationService.invokeFunction( + const { needsConfirmation, realPath } = await this.instantiationService.invokeFunction( accessor => isFileExternalAndNeedsConfirmation(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); - - if (isExternal) { - // Still check content exclusion (copilot ignore) + if (realPath) { await this.instantiationService.invokeFunction( - accessor => assertFileNotContentExcluded(accessor, uri!) + accessor => assertFileNotContentExcluded(accessor, realPath) ); + } + if (needsConfirmation) { const folderUri = dirname(uri); - const message = this.workspaceService.getWorkspaceFolders().length === 1 ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); + const message = realPath + ? this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current folder.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current workspace.`) + : this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); // Return confirmation request for external file // The folder-based "allow this session" option is provided by the core confirmation contribution @@ -246,8 +256,6 @@ export class ReadFileTool implements ICopilotTool { }; } - await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory })); - try { documentSnapshot = await this.getSnapshot(uri); } catch (e) { diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index 6cecf0efc9acb7..b4bc3dd841a5d2 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -28,7 +28,7 @@ import { SearchSubagentToolCallingLoop, isContextOverflowBadRequest } from '../. import { ToolName } from '../common/toolNames'; import { CopilotToolMode, ICopilotTool, ICopilotToolCtor, ToolRegistry } from '../common/toolsRegistry'; import { stripFinalAnswerTags, updateSubagentInvocation } from './subagentToolUtils'; -import { assertFileOkForTool, isFileExternalAndNeedsConfirmation } from './toolUtils'; +import { assertFileNotContentExcluded, isFileExternalAndNeedsConfirmation } from './toolUtils'; export interface ISearchSubagentParams { @@ -255,42 +255,30 @@ class SearchSubagentTool implements ICopilotTool { try { // Enforce read-only file access via shared toolUtils guards before hydrating. - await this.instantiationService.invokeFunction(accessor => - assertFileOkForTool(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) + const { needsConfirmation, realPath } = await this.instantiationService.invokeFunction( + accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) ); - const document = await this.workspaceService.openTextDocument(uri); - const snapshot = TextDocumentSnapshot.create(document); - - const clampedStartLine = Math.max(1, Math.min(startLine, snapshot.lineCount)); - const clampedEndLine = Math.max(1, Math.min(endLine, snapshot.lineCount)); - - const range = new Range( - clampedStartLine - 1, 0, - clampedEndLine - 1, Number.MAX_SAFE_INTEGER - ); - - const code = snapshot.getText(range); - processedLines.push(`File: \`${uri.fsPath}\`, lines ${clampedStartLine}-${clampedEndLine}:\n\`\`\`\n${code}\n\`\`\``); - } catch { - // Drop the line entirely for files outside the workspace so we don't - // disclose the path back to the model. For inside-workspace failures - // (e.g. file missing), keep the original line with the error. - let isExternal = false; - try { - isExternal = await this.instantiationService.invokeFunction(accessor => - isFileExternalAndNeedsConfirmation(accessor, uri, this._inputContext, { readOnly: true, workingDirectory }) + if (!needsConfirmation) { + await this.instantiationService.invokeFunction( + accessor => assertFileNotContentExcluded(accessor, uri, realPath) + ); + const document = await this.workspaceService.openTextDocument(uri); + const snapshot = TextDocumentSnapshot.create(document); + const clampedStartLine = Math.max(1, Math.min(startLine, snapshot.lineCount)); + const clampedEndLine = Math.max(1, Math.min(endLine, snapshot.lineCount)); + const range = new Range( + clampedStartLine - 1, 0, + clampedEndLine - 1, Number.MAX_SAFE_INTEGER ); - } catch { - // isFileExternalAndNeedsConfirmation throws for nonexistent files; - // treat that as "not external" so the original line is preserved. - } - if (!isExternal) { - // If hydration fails (e.g. the captured path didn't resolve because the model's formatting drifted), - // keep the original line so the main agent still gets the model's answer instead of a noisy error suffix. - processedLines.push(line); + const code = snapshot.getText(range); + processedLines.push(`File: \`${uri.fsPath}\`, lines ${clampedStartLine}-${clampedEndLine}:\n\`\`\`\n${code}\n\`\`\``); } + } catch { + // If hydration fails (for example, because the captured path does not exist), + // keep the original line so the main agent still gets the model's answer. + processedLines.push(line); } if (token.isCancellationRequested) { diff --git a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts index 724da1558d1318..024135219f2618 100644 --- a/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts @@ -272,8 +272,7 @@ suite('SearchSubagentTool', () => { test('drops the line when the path is outside the workspace', async () => { const { tool } = makeToolInstance(false, 4, { invokeFunction: sequencedInvokeFunction( - () => { throw new Error('outside workspace'); }, - true, + { needsConfirmation: true, realPath: undefined }, ), }); @@ -286,8 +285,8 @@ suite('SearchSubagentTool', () => { test('keeps the original line when an inside-workspace path fails to open', async () => { const { tool } = makeToolInstance(false, 4, { invokeFunction: sequencedInvokeFunction( + { needsConfirmation: false, realPath: undefined }, undefined, - false, ), openTextDocument: async () => { throw new Error('file not found'); }, }); @@ -305,7 +304,10 @@ suite('SearchSubagentTool', () => { const uri = URI.joinPath(URI.file(cwd), filePath); const { tool } = makeToolInstance(false, 4, { - invokeFunction: sequencedInvokeFunction(undefined), + invokeFunction: sequencedInvokeFunction( + { needsConfirmation: false, realPath: undefined }, + undefined, + ), openTextDocument: async () => makeFakeDocument(uri, fileText), }); diff --git a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts index 098b8c67ccd9cf..4fa96d0f782231 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { afterAll, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, suite, test } from 'vitest'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService'; @@ -18,6 +21,7 @@ import { WorkingDirectory } from '../../../../platform/workspace/common/workingD import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { ResourceSet } from '../../../../util/vs/base/common/map'; import { posix } from '../../../../util/vs/base/common/path'; +import { isWindows } from '../../../../util/vs/base/common/platform'; import { URI } from '../../../../util/vs/base/common/uri'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -25,7 +29,7 @@ import { ChatVariablesCollection, CustomizationsIndexId } from '../../../prompt/ import { IBuildPromptContext } from '../../../prompt/common/intents'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { encodeUrlHostname } from '../../common/toolUtils'; -import { assertFileOkForTool, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isFileExternalAndNeedsConfirmation } from '../toolUtils'; +import { assertFileNotContentExcluded, inputGlobToPattern, isDirExternalAndNeedsConfirmation, isExternalSymlinkedFile, isFileExternalAndNeedsConfirmation } from '../toolUtils'; class TestIgnoreService extends NullIgnoreService { private readonly _ignoredUris = new Set(); @@ -70,10 +74,6 @@ suite('toolUtils - additionalReadAccessPaths', () => { ignoreService.setIgnoredUris([]); }); - function invokeAssertFileOkForTool(uri: URI, readOnly?: boolean) { - return instantiationService.invokeFunction(acc => assertFileOkForTool(acc, uri, undefined, readOnly ? { readOnly } : undefined)); - } - function invokeIsFileExternalAndNeedsConfirmation(uri: URI, readOnly?: boolean) { return instantiationService.invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri, undefined, readOnly ? { readOnly } : undefined)); } @@ -82,84 +82,28 @@ suite('toolUtils - additionalReadAccessPaths', () => { return instantiationService.invokeFunction(acc => isDirExternalAndNeedsConfirmation(acc, uri, undefined, readOnly ? { readOnly } : undefined)); } - describe('assertFileOkForTool', () => { - test('workspace files are always allowed', async () => { - await expect(invokeAssertFileOkForTool(URI.file('/workspace/file.ts'))).resolves.toBeUndefined(); - }); - - test('external file throws without additionalReadAccessPaths', async () => { - await expect(invokeAssertFileOkForTool(URI.file('/external/file.ts'), true)) - .rejects.toThrow(/outside of the workspace/); - }); - - test('external file allowed when under additionalReadAccessPaths with readOnly', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - await expect(invokeAssertFileOkForTool(URI.file('/external/file.ts'), true)).resolves.toBeUndefined(); - }); - - test('nested file under additionalReadAccessPaths is allowed', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - await expect(invokeAssertFileOkForTool(URI.file('/external/deep/nested/file.ts'), true)).resolves.toBeUndefined(); - }); + describe('assertFileNotContentExcluded', () => { + test('rejects an excluded URI', async () => { + const uri = URI.file('/workspace/secret.ts'); + ignoreService.setIgnoredUris([uri]); - test('exact folder path is allowed', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external/folder']); - await expect(invokeAssertFileOkForTool(URI.file('/external/folder'), true)).resolves.toBeUndefined(); - }); - - test('sibling of additional path is not allowed', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external/folder']); - await expect(invokeAssertFileOkForTool(URI.file('/external/other/file.ts'), true)) - .rejects.toThrow(/outside of the workspace/); - }); - - test('parent of additional path is not allowed', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external/folder/sub']); - await expect(invokeAssertFileOkForTool(URI.file('/external/folder/file.ts'), true)) - .rejects.toThrow(/outside of the workspace/); - }); - - test('additional paths are NOT honored without readOnly flag', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - await expect(invokeAssertFileOkForTool(URI.file('/external/file.ts'), false)) - .rejects.toThrow(/outside of the workspace/); - }); - - test('additional paths are NOT honored when readOnly is undefined', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - await expect(invokeAssertFileOkForTool(URI.file('/external/file.ts'))) - .rejects.toThrow(/outside of the workspace/); - }); - - test('multiple additional paths are checked', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/path1', '/path2', '/path3']); - await expect(invokeAssertFileOkForTool(URI.file('/path2/file.ts'), true)).resolves.toBeUndefined(); - await expect(invokeAssertFileOkForTool(URI.file('/path3/deep/file.ts'), true)).resolves.toBeUndefined(); - }); - - test('copilotignore overrides additionalReadAccessPaths', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - ignoreService.setIgnoredUris([URI.file('/external/secret.ts')]); - await expect(invokeAssertFileOkForTool(URI.file('/external/secret.ts'), true)) + await expect(instantiationService.invokeFunction(accessor => assertFileNotContentExcluded(accessor, uri))) .rejects.toThrow(/configured to be ignored by Copilot/); }); - test('copilotignore overrides workspace membership', async () => { - ignoreService.setIgnoredUris([URI.file('/workspace/secret.ts')]); - await expect(invokeAssertFileOkForTool(URI.file('/workspace/secret.ts'))) - .rejects.toThrow(/configured to be ignored by Copilot/); - }); + test('rejects an excluded resolved target', async () => { + const uri = URI.file('/workspace/link.ts'); + const realPath = URI.file('/workspace/secret.ts'); + ignoreService.setIgnoredUris([realPath]); - test('empty additional paths array has no effect', async () => { - await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, []); - await expect(invokeAssertFileOkForTool(URI.file('/external/file.ts'), true)) - .rejects.toThrow(/outside of the workspace/); + await expect(instantiationService.invokeFunction(accessor => assertFileNotContentExcluded(accessor, uri, realPath))) + .rejects.toThrow(/configured to be ignored by Copilot/); }); }); describe('isFileExternalAndNeedsConfirmation', () => { test('workspace file does not need confirmation', async () => { - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file that does not exist throws', async () => { @@ -174,17 +118,17 @@ suite('toolUtils - additionalReadAccessPaths', () => { test('non-existent workspace file does not need confirmation', async () => { // Non-existent files within the workspace should also not trigger confirmation - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file under additional paths with readOnly does not need confirmation', async () => { await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('nested file under additional paths with readOnly does not need confirmation', async () => { await configService.setConfig(ConfigKey.AdditionalReadAccessPaths, ['/external']); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/deep/nested/file.ts'), true)).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('external file under additional paths without readOnly throws when file does not exist', async () => { @@ -228,10 +172,6 @@ suite('toolUtils - additionalReadAccessPaths', () => { describe('workingDirectory support', () => { const workingDir = URI.file('/my-project'); - function invokeAssertFileOkWithWd(uri: URI) { - return instantiationService.invokeFunction(acc => assertFileOkForTool(acc, uri, undefined, { workingDirectory: workingDir })); - } - function invokeIsFileExternalWithWd(uri: URI) { return instantiationService.invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri, undefined, { readOnly: true, workingDirectory: workingDir })); } @@ -240,23 +180,8 @@ suite('toolUtils - additionalReadAccessPaths', () => { return instantiationService.invokeFunction(acc => isDirExternalAndNeedsConfirmation(acc, uri, undefined, { readOnly: true, workingDirectory: workingDir })); } - test('assertFileOkForTool allows file within workingDirectory', async () => { - await expect(invokeAssertFileOkWithWd(URI.file('/my-project/src/index.ts'))).resolves.toBeUndefined(); - }); - - test('assertFileOkForTool rejects file outside workingDirectory', async () => { - await expect(invokeAssertFileOkWithWd(URI.file('/other-project/file.ts'))) - .rejects.toThrow(/outside of the workspace/); - }); - - test('assertFileOkForTool rejects workspace file when workingDirectory is set', async () => { - // /workspace is the workspace folder, but workingDirectory overrides it - await expect(invokeAssertFileOkWithWd(URI.file('/workspace/file.ts'))) - .rejects.toThrow(/outside of the workspace/); - }); - test('isFileExternalAndNeedsConfirmation: file within workingDirectory is not external', async () => { - expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toBe(false); + expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('isFileExternalAndNeedsConfirmation: workspace file is external when workingDirectory is set', async () => { @@ -383,7 +308,7 @@ suite('toolUtils - external file existence', () => { test('external file that exists needs confirmation', async () => { // Mock an external file that actually exists mockFs.mockFile(URI.file('/external/existing-file.ts'), 'content'); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toBe(true); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/external/existing-file.ts'))).toEqual({ needsConfirmation: true, realPath: undefined }); }); test('external file that does not exist throws', async () => { @@ -395,12 +320,130 @@ suite('toolUtils - external file existence', () => { test('workspace file does not need confirmation even if it exists', async () => { // Mock a workspace file mockFs.mockFile(URI.file('/workspace/file.ts'), 'content'); - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/file.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); }); test('workspace file does not need confirmation even if it does not exist', async () => { // Non-existent workspace file - expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toBe(false); + expect(await invokeIsFileExternalAndNeedsConfirmation(URI.file('/workspace/nonexistent.ts'))).toEqual({ needsConfirmation: false, realPath: undefined }); + }); +}); + +describe.skipIf(isWindows)('isExternalSymlinkedFile', () => { + let temporaryDirectory: string; + let workspaceDirectory: string; + let externalDirectory: string; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync(path.join(tmpdir(), 'toolutils-symlink-')); + workspaceDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'workspace-'))); + externalDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'external-'))); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + function getFolder(uri: URI): URI | undefined { + const workspaceUri = URI.file(workspaceDirectory); + return uri.fsPath === workspaceDirectory || uri.fsPath.startsWith(`${workspaceDirectory}${path.sep}`) ? workspaceUri : undefined; + } + + async function invokeIsFileExternal(uri: URI, workspaceDirectories = [workspaceDirectory]) { + const services = createExtensionUnitTestingServices(); + services.define(IWorkspaceService, new SyncDescriptor( + TestWorkspaceService, + [workspaceDirectories.map(URI.file), []] + )); + const accessor = services.createTestingAccessor(); + try { + const result = await accessor.get(IInstantiationService).invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri)); + return { ...result, realPath: result.realPath?.fsPath }; + } finally { + accessor.dispose(); + } + } + + test('returns true when the file symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalFile, symlinkedFile); + + await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(true); + }); + + test('returns the real path when a workspace symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalFile, symlinkedFile); + + await expect(invokeIsFileExternal(URI.file(symlinkedFile))).resolves.toEqual({ + needsConfirmation: true, + realPath: externalFile, + }); + }); + + test('does not require confirmation when a workspace symlink points into another workspace folder', async () => { + const secondWorkspaceDirectory = fs.realpathSync(fs.mkdtempSync(path.join(temporaryDirectory, 'workspace-'))); + const targetFile = path.join(secondWorkspaceDirectory, 'target.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(targetFile, 'content'); + fs.symlinkSync(targetFile, symlinkedFile); + + await expect(invokeIsFileExternal(URI.file(symlinkedFile), [workspaceDirectory, secondWorkspaceDirectory])).resolves.toEqual({ + needsConfirmation: false, + realPath: targetFile, + }); + }); + + test('returns the real path without confirmation when a symlink target is inside the workspace', async () => { + const targetFile = path.join(workspaceDirectory, 'target.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(targetFile, 'content'); + fs.symlinkSync(targetFile, symlinkedFile); + + await expect(invokeIsFileExternal(URI.file(symlinkedFile))).resolves.toEqual({ + needsConfirmation: false, + realPath: targetFile, + }); + }); + + test('returns true when a parent directory symlink points outside the workspace', async () => { + const externalFile = path.join(externalDirectory, 'external.txt'); + const symlinkedDirectory = path.join(workspaceDirectory, 'linked'); + fs.writeFileSync(externalFile, 'content'); + fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir'); + + await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'external.txt')), getFolder)).resolves.toBe(true); + }); + + test('returns true for a nonexistent file under a parent directory symlink that points outside the workspace', async () => { + const symlinkedDirectory = path.join(workspaceDirectory, 'linked'); + fs.symlinkSync(externalDirectory, symlinkedDirectory, 'dir'); + + await expect(isExternalSymlinkedFile(URI.file(path.join(symlinkedDirectory, 'missing.txt')), getFolder)).resolves.toBe(true); + }); + + test('returns false when the symlink target is inside the workspace', async () => { + const targetFile = path.join(workspaceDirectory, 'target.txt'); + const symlinkedFile = path.join(workspaceDirectory, 'linked.txt'); + fs.writeFileSync(targetFile, 'content'); + fs.symlinkSync(targetFile, symlinkedFile); + + await expect(isExternalSymlinkedFile(URI.file(symlinkedFile), getFolder)).resolves.toBe(false); + }); + + test('returns false when the file is not a symlink', async () => { + const file = path.join(workspaceDirectory, 'file.txt'); + fs.writeFileSync(file, 'content'); + + await expect(isExternalSymlinkedFile(URI.file(file), getFolder)).resolves.toBe(false); + }); + + test('returns false when the file does not exist', async () => { + await expect(isExternalSymlinkedFile(URI.file(path.join(workspaceDirectory, 'missing.txt')), getFolder)).resolves.toBe(false); }); }); diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index e799cc8ab38084..d025c15d70feac 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptPiece } from '@vscode/prompt-tsx'; +import { realpath } from 'fs/promises'; +import * as path from 'path'; import type * as vscode from 'vscode'; import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; @@ -161,63 +163,9 @@ export function resolveToolInputPath(path: string, promptPathRepresentationServi return uri; } -export async function isFileOkForTool(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext): Promise { - try { - await assertFileOkForTool(accessor, uri, buildPromptContext); - return true; - } catch { - return false; - } -} - -export interface AssertFileOkForToolOptions { - readOnly?: boolean; - workingDirectory?: URI; -} - -export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: AssertFileOkForToolOptions): Promise { - const workspaceService = accessor.get(IWorkspaceService); - const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); - const promptPathRepresentationService = accessor.get(IPromptPathRepresentationService); - const customInstructionsService = accessor.get(ICustomInstructionsService); - const diskSessionResources = accessor.get(IChatDiskSessionResources); - const configurationService = accessor.get(IConfigurationService); - const chatDebugFileLogger = accessor.get(IChatDebugFileLoggerService); - const sessionTranscriptService = accessor.get(ISessionTranscriptService); - - await assertFileNotContentExcluded(accessor, uri); - - const normalizedUri = normalizePath(uri); - const workingDir = new WorkingDirectory(options?.workingDirectory, workspaceService); - if (workingDir.getFolder(normalizedUri)) { - return; - } - if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { - return; - } - if (uri.scheme === Schemas.untitled) { - return; - } - const fileOpenInSomeTab = tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri)); - if (fileOpenInSomeTab) { - return; - } - if (diskSessionResources.isSessionResourceUri(normalizedUri)) { - return; - } - if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) { - return; - } - if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { - return; - } - if (normalizedUri.scheme === 'vscode-chat-response-resource') { - return; - } - if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) { - return; - } - throw new Error(`File ${promptPathRepresentationService.getFilePath(normalizedUri)} is outside of the workspace, and not open in an editor, and can't be read`); +interface FileExternalConfirmationOptions { + readonly readOnly?: boolean; + readonly workingDirectory?: URI; } async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise { @@ -272,16 +220,30 @@ function getInstructionsIndexFile(buildPromptContext: IBuildPromptContext, custo } -export async function assertFileNotContentExcluded(accessor: ServicesAccessor, uri: URI): Promise { +export async function assertFileNotContentExcluded(accessor: ServicesAccessor, uri: URI, realPath?: URI): Promise { const ignoreService = accessor.get(IIgnoreService); const promptPathRepresentationService = accessor.get(IPromptPathRepresentationService); - if (await ignoreService.isCopilotIgnored(uri)) { throw new Error(`File ${promptPathRepresentationService.getFilePath(uri)} is configured to be ignored by Copilot`); } + if (realPath && !extUriBiasedIgnorePathCase.isEqual(realPath, uri) && await ignoreService.isCopilotIgnored(realPath)) { + throw new Error(`File ${promptPathRepresentationService.getFilePath(realPath)} is configured to be ignored by Copilot`); + } } -export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): Promise { +export interface FileExternalConfirmationResult { + readonly needsConfirmation: boolean; + readonly realPath: URI | undefined; +} + +export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: FileExternalConfirmationOptions): Promise { + const instantiationService = accessor.get(IInstantiationService); + return instantiationService.invokeFunction( + accessor => getFileExternalConfirmation(accessor, uri, buildPromptContext, options, true) + ); +} + +async function getFileExternalConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext: IBuildPromptContext | undefined, options: FileExternalConfirmationOptions | undefined, requireExistingExternalFile: boolean): Promise { const workspaceService = accessor.get(IWorkspaceService); const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const customInstructionsService = accessor.get(ICustomInstructionsService); @@ -295,38 +257,111 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces const workingDir = new WorkingDirectory(options?.workingDirectory, workspaceService); if (workingDir.getFolder(normalizedUri)) { - return false; + return getWorkspaceFileExternalConfirmation(normalizedUri, uri => workingDir.getFolder(uri)); } if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (uri.scheme === Schemas.untitled || uri.scheme === 'vscode-chat-response-resource') { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (diskSessionResources.isSessionResourceUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { - return false; + return { needsConfirmation: false, realPath: undefined }; } if (tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri))) { - return false; + return { needsConfirmation: false, realPath: undefined }; } - // If the file doesn't exist, throw immediately rather than showing a confusing "external file" - // confirmation — the tool should fail with a clear "file not found" error instead. - const fileExists = await fileSystemService.stat(normalizedUri).then(() => true).catch(() => false); - if (!fileExists) { - throw new Error(`File ${normalizedUri.fsPath} does not exist`); + if (requireExistingExternalFile) { + // Avoid showing a confusing external-file confirmation when the tool will ultimately fail. + const fileExists = await fileSystemService.stat(normalizedUri).then(() => true).catch(() => false); + if (!fileExists) { + throw new Error(`File ${normalizedUri.fsPath} does not exist`); + } } - return true; + return { needsConfirmation: true, realPath: undefined }; +} + +/** + * Checks whether a symlinked file resolves outside the workspace. + */ +export async function isExternalSymlinkedFile(uri: URI, getFolder: (uri: URI) => URI | undefined): Promise { + return (await getWorkspaceFileExternalConfirmation(uri, getFolder)).needsConfirmation; +} + +async function getWorkspaceFileExternalConfirmation(uri: URI, getFolder: (uri: URI) => URI | undefined): Promise { + if (uri.scheme !== Schemas.file) { + return { needsConfirmation: false, realPath: undefined }; + } + + const workspaceFolder = getFolder(uri); + if (!workspaceFolder || workspaceFolder.scheme !== Schemas.file) { + return { needsConfirmation: false, realPath: undefined }; + } + + const resolvedUri = normalizePath(await resolveRealPathForNonexistent(uri, workspaceFolder)); + if (extUriBiasedIgnorePathCase.isEqual(resolvedUri, uri)) { + return { needsConfirmation: false, realPath: undefined }; + } + + const isInsideWorkspace = getFolder(resolvedUri) !== undefined; + return { needsConfirmation: !isInsideWorkspace, realPath: resolvedUri }; +} + +/** + * Resolves a path through its nearest existing ancestor without walking above `stopAt`. + */ +export async function resolveRealPathForNonexistent(resource: URI, stopAt?: URI): Promise { + try { + return URI.file(await realpath(resource.fsPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const tail = [path.basename(resource.fsPath)]; + let current = path.dirname(resource.fsPath); + while (true) { + if (stopAt && isEqual(normalizePath(URI.file(current)), normalizePath(stopAt))) { + try { + return URI.file(path.join(await realpath(stopAt.fsPath), ...tail)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return resource; + } + throw error; + } + } + + const parent = path.dirname(current); + if (parent === current) { + // On Windows, resolving `\` adds the current drive and can make an unchanged path appear redirected. + return resource; + } + + try { + return URI.file(path.join(await realpath(current), ...tail)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } + } + + tail.unshift(path.basename(current)); + current = parent; + } } export function isDirExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): boolean { diff --git a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx index a807903f6113f9..000f43d0a28f08 100644 --- a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx +++ b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx @@ -18,7 +18,7 @@ import { ToolName } from '../common/toolNames'; import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; import { formatUriForFileWidget } from '../common/toolUtils'; import { getImageMimeType, MAX_IMAGE_FILE_SIZE } from './imageToolUtils'; -import { assertFileNotContentExcluded, assertFileOkForTool, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; +import { assertFileNotContentExcluded, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; export interface IViewImageParams { filePath: string; @@ -63,19 +63,28 @@ export class ViewImageTool implements ICopilotTool { const uri = resolveToolInputPath(options.input.filePath, this.promptPathRepresentationService); this.assertImageFile(uri); - const isExternal = await this.instantiationService.invokeFunction( - accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) + await this.instantiationService.invokeFunction( + accessor => assertFileNotContentExcluded(accessor, uri) ); - if (isExternal) { + const { needsConfirmation, realPath } = await this.instantiationService.invokeFunction( + accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) + ); + if (realPath) { await this.instantiationService.invokeFunction( - accessor => assertFileNotContentExcluded(accessor, uri) + accessor => assertFileNotContentExcluded(accessor, realPath) ); + } + if (needsConfirmation) { const folderUri = dirname(uri); - const message = this.workspaceService.getWorkspaceFolders().length === 1 - ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) - : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); + const message = realPath + ? this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current folder.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} links to ${formatUriForFileWidget(realPath)}, which is outside the current workspace.`) + : this.workspaceService.getWorkspaceFolders().length === 1 + ? new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current folder in ${formatUriForFileWidget(folderUri)}.`) + : new MarkdownString(l10n.t`${formatUriForFileWidget(uri)} is outside of the current workspace in ${formatUriForFileWidget(folderUri)}.`); return { invocationMessage: new MarkdownString(l10n.t`Viewing image ${formatUriForFileWidget(uri)}`), @@ -87,8 +96,6 @@ export class ViewImageTool implements ICopilotTool { }; } - await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory })); - return { invocationMessage: new MarkdownString(l10n.t`Viewing image ${formatUriForFileWidget(uri)}`), pastTenseMessage: new MarkdownString(l10n.t`Viewed image ${formatUriForFileWidget(uri)}`), diff --git a/extensions/copilot/src/platform/authentication/common/authentication.ts b/extensions/copilot/src/platform/authentication/common/authentication.ts index 07c7c9de3af9a2..6d6227d7cebb6a 100644 --- a/extensions/copilot/src/platform/authentication/common/authentication.ts +++ b/extensions/copilot/src/platform/authentication/common/authentication.ts @@ -278,7 +278,6 @@ export abstract class BaseAuthenticationService extends Disposable implements IA //#region Copilot Token - private _copilotTokenError: Error | undefined; get copilotToken(): CopilotToken | undefined { return this._tokenStore.copilotToken; } @@ -287,7 +286,6 @@ export abstract class BaseAuthenticationService extends Disposable implements IA const tokenBefore = this._tokenStore.copilotToken; const token = await this._tokenManager.getCopilotToken(force); this._tokenStore.copilotToken = token; - this._copilotTokenError = undefined; if (tokenBefore?.token !== token.token) { this.fireCopilotTokenChange('getCopilotToken'); } @@ -295,16 +293,9 @@ export abstract class BaseAuthenticationService extends Disposable implements IA } catch (afterError) { const tokenBefore = this._tokenStore.copilotToken; this._tokenStore.copilotToken = undefined; - const beforeError = this._copilotTokenError; - this._copilotTokenError = afterError; if (tokenBefore) { // Had a valid token before, now errored — token value changed to undefined this.fireCopilotTokenChange('getCopilotToken token lost'); - } else if (beforeError && afterError && beforeError.message !== afterError.message) { - // Still can't get a Copilot Token, but the error has changed. - // I.e. They go from being not signed in (no copilot token can be minted) - // to an account that doesn't have a valid subscription (no copilot token can be minted). - this.fireCopilotTokenChange('getCopilotToken error change'); } throw afterError; } diff --git a/extensions/copilot/src/platform/authentication/common/copilotToken.ts b/extensions/copilot/src/platform/authentication/common/copilotToken.ts index 4f795358e08ba0..97173ebb4469a6 100644 --- a/extensions/copilot/src/platform/authentication/common/copilotToken.ts +++ b/extensions/copilot/src/platform/authentication/common/copilotToken.ts @@ -633,6 +633,8 @@ export type SuccessNotificationId = export type TokenError = { reason: TokenErrorReason; + /** Milliseconds the caller should wait before retrying a rate-limited request. */ + retryAfterMs?: number; notification_id?: TokenErrorNotificationId | string; message?: string; /** URL for action button to help user resolve the error. */ diff --git a/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts b/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts index b004540f0a79cd..7401790ea358cc 100644 --- a/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts +++ b/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { RequestType } from '@vscode/copilot-api'; +import { retryAfterFromRateLimitHeaders } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle'; import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors'; @@ -28,6 +29,7 @@ type FetchTokenResult = { ok: boolean; status: number; statusText: string; + retryAfterMs?: number; } & ( // success | { body: TokenEnvelope; kind: 'token' } @@ -180,6 +182,10 @@ export abstract class BaseCopilotTokenManager extends Disposable implements ICop // Handle HTTP errors if (!result.ok) { this._logService.warn(`Failed to get copilot token due to status ${result.status} ${result.statusText}`); + if (result.status === 429) { + this._telemetryService.sendGHTelemetryErrorEvent('auth.rate_limited'); + return { kind: 'failure', reason: 'RateLimited', retryAfterMs: result.retryAfterMs }; + } const data = TelemetryData.createAndMarkAsIssued({ status: result.status.toString(), status_text: result.statusText, @@ -206,7 +212,7 @@ export abstract class BaseCopilotTokenManager extends Disposable implements ICop if (result.body.message?.startsWith('API rate limit exceeded')) { this._logService.warn('Failed to get copilot token due to exceeding API rate limit'); this._telemetryService.sendGHTelemetryErrorEvent('auth.rate_limited'); - return { kind: 'failure', reason: 'RateLimited' }; + return { kind: 'failure', reason: 'RateLimited', retryAfterMs: result.retryAfterMs }; } this._logService.warn(`Failed to get copilot token due to: ${result.body.message}`); return { kind: 'failure', reason: 'NotAuthorized' }; @@ -290,7 +296,12 @@ export abstract class BaseCopilotTokenManager extends Disposable implements ICop * Returns a structured result with HTTP status and validated body. */ private async parseTokenResponse(response: Response): Promise { - const httpInfo = { ok: response.ok, status: response.status, statusText: response.statusText }; + const httpInfo = { + ok: response.ok, + status: response.status, + statusText: response.statusText, + retryAfterMs: retryAfterFromRateLimitHeaders(response.headers), + }; let parsed: unknown; try { diff --git a/extensions/copilot/src/platform/authentication/test/node/authentication.spec.ts b/extensions/copilot/src/platform/authentication/test/node/authentication.spec.ts index 5a3f1802f6d74d..a3c038a4039658 100644 --- a/extensions/copilot/src/platform/authentication/test/node/authentication.spec.ts +++ b/extensions/copilot/src/platform/authentication/test/node/authentication.spec.ts @@ -16,6 +16,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { createPlatformServices } from '../../../test/node/services'; import { StaticGitHubAuthenticationService } from '../../common/staticGitHubAuthenticationService'; import { CopilotToken, createTestExtendedTokenInfo } from '../../common/copilotToken'; +import { ICopilotTokenManager } from '../../common/copilotTokenManager'; import { ICopilotTokenStore } from '../../common/copilotTokenStore'; import { FixedCopilotTokenManager } from '../../node/copilotTokenManager'; @@ -109,4 +110,66 @@ suite('AuthenticationService', function () { await promise; expect(authenticationService.copilotToken?.token).toBe(newToken); }); + + test('Does not emit onDidCopilotTokenChange when token errors change', async () => { + const accessor = disposables.add(createPlatformServices().createTestingAccessor()); + const failingTokenManager = new ScriptedCopilotTokenManager([ + new Error('first failure'), + new Error('second failure'), + ]); + const service = disposables.add(new StaticGitHubAuthenticationService( + () => testToken, + accessor.get(ILogService), + accessor.get(ICopilotTokenStore), + failingTokenManager, + accessor.get(IConfigurationService), + )); + const tokenChangeSpy = vi.fn(); + service.onDidCopilotTokenChange(tokenChangeSpy); + + await expect(service.getCopilotToken()).rejects.toThrow('first failure'); + await expect(service.getCopilotToken()).rejects.toThrow('second failure'); + + expect(tokenChangeSpy).not.toHaveBeenCalled(); + }); + + test('Emits onDidCopilotTokenChange when a token is gained and lost', async () => { + const accessor = disposables.add(createPlatformServices().createTestingAccessor()); + const token = new CopilotToken(createTestExtendedTokenInfo({ token: 'tid=scripted' })); + const tokenManager = new ScriptedCopilotTokenManager([token, new Error('token lost')]); + const service = disposables.add(new StaticGitHubAuthenticationService( + () => testToken, + accessor.get(ILogService), + accessor.get(ICopilotTokenStore), + tokenManager, + accessor.get(IConfigurationService), + )); + const observedTokens: Array = []; + service.onDidCopilotTokenChange(() => observedTokens.push(service.copilotToken?.token)); + + await service.getCopilotToken(); + await expect(service.getCopilotToken()).rejects.toThrow('token lost'); + + expect(observedTokens).toEqual(['tid=scripted', undefined]); + }); }); + +class ScriptedCopilotTokenManager implements ICopilotTokenManager { + declare readonly _serviceBrand: undefined; + readonly onDidCopilotTokenRefresh = Event.None; + + constructor(private readonly results: Array) { } + + async getCopilotToken(): Promise { + const result = this.results.shift(); + if (!result) { + throw new Error('No scripted token result'); + } + if (result instanceof Error) { + throw result; + } + return result; + } + + resetCopilotToken(): void { } +} diff --git a/extensions/copilot/src/platform/authentication/test/node/copilotToken.spec.ts b/extensions/copilot/src/platform/authentication/test/node/copilotToken.spec.ts index 582f7b5c28679b..8e7d82295415a2 100644 --- a/extensions/copilot/src/platform/authentication/test/node/copilotToken.spec.ts +++ b/extensions/copilot/src/platform/authentication/test/node/copilotToken.spec.ts @@ -13,7 +13,7 @@ import { IDomainService } from '../../../endpoint/common/domainService'; import { IEnvService } from '../../../env/common/envService'; import { NullBaseOctoKitService } from '../../../github/common/nullOctokitServiceImpl'; import { ILogService } from '../../../log/common/logService'; -import { FetchOptions, IAbortController, IFetcherService, PaginationOptions, Response, WebSocketConnection } from '../../../networking/common/fetcherService'; +import { FetchOptions, IAbortController, IFetcherService, IHeaders, PaginationOptions, Response, WebSocketConnection } from '../../../networking/common/fetcherService'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { createFakeResponse } from '../../../test/node/fetcher'; import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; @@ -232,6 +232,22 @@ describe('Copilot token unit tests', function () { }); }); + it('rate limiting honors Retry-After', async function () { + const fetcher = new RateLimitedFetcherService(); + const testingServiceCollection = createPlatformServices(); + testingServiceCollection.define(IFetcherService, fetcher); + accessor = disposables.add(testingServiceCollection.createTestingAccessor()); + + const tokenManager = accessor.get(IInstantiationService).createInstance(CopilotTokenManagerFromGitHubToken, 'valid', 'valid-user'); + const result = await tokenManager.checkCopilotToken(); + + expect(result).toEqual({ + kind: 'failure', + reason: 'RateLimited', + retryAfterMs: 120_000, + }); + }); + it('HTTP 401 unauthorized', async function () { const fetcher = new HttpStatusFetcherService(401); @@ -701,3 +717,32 @@ class HttpStatusFetcherService extends StaticFetcherService { return createFakeResponse(this.status, {}); } } + +class RateLimitedFetcherService extends StaticFetcherService { + constructor() { + super({}); + } + + override async fetch(url: string, options: FetchOptions): Promise { + this.requests.set(url, options); + return Response.fromText( + 429, + 'Too Many Requests', + new TestHeaders({ 'retry-after': '120' }), + JSON.stringify({ message: 'rate limited' }), + 'test-stub', + ); + } +} + +class TestHeaders implements IHeaders { + constructor(private readonly headers: Record) { } + + get(name: string): string | null { + return this.headers[name.toLowerCase()] ?? null; + } + + *[Symbol.iterator](): Iterator<[string, string]> { + yield* Object.entries(this.headers); + } +} diff --git a/extensions/copilot/src/platform/authentication/vscode-node/copilotTokenManager.ts b/extensions/copilot/src/platform/authentication/vscode-node/copilotTokenManager.ts index 4152bc524326cd..bf242740a86fcc 100644 --- a/extensions/copilot/src/platform/authentication/vscode-node/copilotTokenManager.ts +++ b/extensions/copilot/src/platform/authentication/vscode-node/copilotTokenManager.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { env, window } from 'vscode'; +import { FetchBlockedError } from '../../../shared-fetch-utils/common/fetchTypes'; +import { DEFAULT_RATE_LIMIT_BACKOFF_MS, MAX_RATE_LIMIT_BACKOFF_MS } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware'; import { TaskSingler } from '../../../util/common/taskSingler'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; @@ -27,7 +29,12 @@ export class SubscriptionExpiredError extends Error { } export class ContactSupportError extends Error { } export class EnterpriseManagedError extends Error { } export class InvalidTokenError extends Error { } -export class RateLimitedError extends Error { } +export class RateLimitedError extends FetchBlockedError { + constructor(retryAfterMs: number) { + const boundedRetryAfterMs = Math.min(retryAfterMs, MAX_RATE_LIMIT_BACKOFF_MS); + super('Your account has exceeded GitHub\'s API rate limit. Please try again later.', boundedRetryAfterMs); + } +} export class GitHubLoginFailedError extends ErrorNoTelemetry { } export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { @@ -93,6 +100,8 @@ export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { if (tokenResult.kind === 'success') { this._logService.info(`Got Copilot token for devDeviceId`); this._logService.info(`Copilot Chat: ${this._envService.getVersion()}, VS Code: ${this._envService.vscodeVersion}`); + } else if (tokenResult.reason === 'RateLimited') { + return tokenResult; } else { this._logService.warn('GitHub login failed'); return { kind: 'failure', reason: 'GitHubLoginFailed' }; @@ -137,7 +146,7 @@ export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager { } if (tokenResult.kind === 'failure' && tokenResult.reason === 'RateLimited') { - throw new RateLimitedError(`Your account has exceeded GitHub's API rate limit. Please try again later.`); + throw new RateLimitedError(tokenResult.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS); } if (tokenResult.kind === 'failure') { diff --git a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts index 7068399a223540..d28e0da6efbbe5 100644 --- a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts @@ -101,7 +101,7 @@ export interface IChatDebugFileLoggerService { /** * Check whether a URI is under the debug-logs storage directory. - * Used by {@link assertFileOkForTool} to allowlist tool reads. + * Used by file access checks to allowlist tool reads. */ isDebugLogUri(uri: URI): boolean; diff --git a/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts b/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts index 20946fde38d66e..6929b48ca2ba57 100644 --- a/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts +++ b/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts @@ -251,7 +251,7 @@ export interface ISessionTranscriptService { /** * Check whether a URI is under the transcripts storage directory. - * Used by {@link assertFileOkForTool} to allowlist tool reads. + * Used by file access checks to allowlist tool reads. */ isTranscriptUri(uri: URI): boolean; } diff --git a/extensions/copilot/src/shared-fetch-utils/common/fetchedValue.ts b/extensions/copilot/src/shared-fetch-utils/common/fetchedValue.ts index 6d98efa3f69f75..e054e2cd734763 100644 --- a/extensions/copilot/src/shared-fetch-utils/common/fetchedValue.ts +++ b/extensions/copilot/src/shared-fetch-utils/common/fetchedValue.ts @@ -28,6 +28,13 @@ export interface FetchedValueOptions { * is re-fetched periodically regardless of whether it is being read. */ keepCacheHot?: boolean; + + /** + * Returns how long a failed fetch should prevent another attempt. + * + * {@link FetchBlockedError.retryAfterMs} always takes precedence. + */ + getRetryAfterMs?: (error: unknown) => number | undefined; } /** @@ -59,15 +66,19 @@ export class FetchedValue { private _value: T | undefined; private _hasFetched = false; private _inflightFetch: Promise | undefined; + private _blockedFailure: { readonly error: unknown; readonly until: number; readonly returnCachedValue: boolean } | undefined; + private _generation = 0; private _disposed = false; private _keepCacheHotTimer: ReturnType | undefined; private _fetch: (() => Promise) | undefined; private readonly _isStale: (value: T) => boolean; + private readonly _getRetryAfterMs: ((error: unknown) => number | undefined) | undefined; constructor(options: FetchedValueOptions) { this._fetch = options.fetch; this._isStale = options.isStale; + this._getRetryAfterMs = options.getRetryAfterMs; if (options.keepCacheHot) { this._keepCacheHotTimer = setInterval(() => { this.resolve().catch(() => { /* swallow — next interval will retry */ }); @@ -97,22 +108,49 @@ export class FetchedValue { if (!force && this._hasFetched && !this._isStale(this._value as T)) { return this._value as T; } + if (!force && this._blockedFailure) { + if (Date.now() < this._blockedFailure.until) { + if (this._blockedFailure.returnCachedValue && this._hasFetched) { + return this._value as T; + } + throw this._blockedFailure.error; + } + this._blockedFailure = undefined; + } if (this._inflightFetch) { return this._inflightFetch; } - this._inflightFetch = this._doFetch(); + const inflightFetch = this._doFetch(this._generation); + this._inflightFetch = inflightFetch; try { - return await this._inflightFetch; + return await inflightFetch; } finally { - this._inflightFetch = undefined; + if (this._inflightFetch === inflightFetch) { + this._inflightFetch = undefined; + } } } + /** + * Clears the cached value and retry state. In-flight work may finish for its caller, but cannot + * repopulate this cache. + */ + invalidate(): void { + this._throwIfDisposed(); + this._generation++; + this._value = undefined; + this._hasFetched = false; + this._inflightFetch = undefined; + this._blockedFailure = undefined; + } + dispose(): void { this._disposed = true; + this._generation++; this._value = undefined; this._hasFetched = false; this._inflightFetch = undefined; + this._blockedFailure = undefined; this._fetch = undefined; if (this._keepCacheHotTimer !== undefined) { clearInterval(this._keepCacheHotTimer); @@ -120,17 +158,25 @@ export class FetchedValue { } } - private async _doFetch(): Promise { + private async _doFetch(generation: number): Promise { this._throwIfDisposed(); try { const newValue = await this._fetch!(); this._throwIfDisposed(); - this._value = newValue; - this._hasFetched = true; + if (generation === this._generation) { + this._value = newValue; + this._hasFetched = true; + this._blockedFailure = undefined; + } return newValue; } catch (err) { - if (err instanceof FetchBlockedError && this._hasFetched) { - return this._value as T; + const retryAfterMs = err instanceof FetchBlockedError ? err.retryAfterMs : this._getRetryAfterMs?.(err); + if (generation === this._generation && retryAfterMs !== undefined && retryAfterMs > 0) { + const returnCachedValue = err instanceof FetchBlockedError; + this._blockedFailure = { error: err, until: Date.now() + retryAfterMs, returnCachedValue }; + if (returnCachedValue && this._hasFetched) { + return this._value as T; + } } throw err; } @@ -142,5 +188,3 @@ export class FetchedValue { } } } - - diff --git a/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts b/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts index 0830e48c68e533..c615b93b1d11bf 100644 --- a/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts +++ b/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts @@ -5,6 +5,9 @@ import { FetchBlockedError, type FetchMiddleware, type HttpHeaders } from '../fetchTypes'; +export const DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000; +export const MAX_RATE_LIMIT_BACKOFF_MS = 15 * 60_000; + export class RateLimitBackoffError extends FetchBlockedError { constructor(retryAfterMs: number) { super(`Rate limited, backing off for ${Math.round(retryAfterMs / 1000)}s`, retryAfterMs); @@ -33,8 +36,8 @@ export interface RateLimitBackoffOptions { */ export function rateLimitBackoffMiddleware(options?: RateLimitBackoffOptions): FetchMiddleware { const { - initialDelayMs = 60_000, - maxDelayMs = 15 * 60_000, + initialDelayMs = DEFAULT_RATE_LIMIT_BACKOFF_MS, + maxDelayMs = MAX_RATE_LIMIT_BACKOFF_MS, multiplier = 2, now = Date.now, } = options ?? {}; @@ -60,7 +63,7 @@ export function rateLimitBackoffMiddleware(options?: RateLimitBackoffOptions): F } consecutiveRateLimits++; - const hinted = retryAfterFromHeaders(response.headers, now); + const hinted = retryAfterFromRateLimitHeaders(response.headers, now); const backoff = hinted ?? initialDelayMs * Math.pow(multiplier, consecutiveRateLimits - 1); // `maxDelayMs` caps the server's hint too, so a bogus or hostile `Retry-After` cannot stall // the client indefinitely. Retrying a little early simply re-arms the backoff. @@ -79,7 +82,7 @@ function isRateLimited(status: number, headers: HttpHeaders): boolean { return status === 403 && readHeader(headers, 'x-ratelimit-remaining') === '0'; } -function retryAfterFromHeaders(headers: HttpHeaders, now: () => number): number | undefined { +export function retryAfterFromRateLimitHeaders(headers: HttpHeaders, now: () => number = Date.now): number | undefined { const retryAfter = Number(readHeader(headers, 'retry-after')); if (Number.isFinite(retryAfter) && retryAfter > 0) { return retryAfter * 1000; diff --git a/extensions/copilot/src/shared-fetch-utils/common/test/fetchedValue.spec.ts b/extensions/copilot/src/shared-fetch-utils/common/test/fetchedValue.spec.ts index bc48f156f8a0b7..da38a71ed1d74d 100644 --- a/extensions/copilot/src/shared-fetch-utils/common/test/fetchedValue.spec.ts +++ b/extensions/copilot/src/shared-fetch-utils/common/test/fetchedValue.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FetchedValue, FetchedValueOptions } from '../fetchedValue'; import { FetchBlockedError } from '../fetchTypes'; @@ -34,6 +34,10 @@ describe('FetchedValue', () => { fetchedValue = createFetchedValue(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('value is undefined before first resolve', () => { expect(fetchedValue.value).toBeUndefined(); }); @@ -113,15 +117,121 @@ describe('FetchedValue', () => { }); it('FetchBlockedError propagates when no cached value exists', async () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + let blockedFetchCount = 0; + const fv = new FetchedValue({ + fetch: async () => { + blockedFetchCount++; + throw new FetchBlockedError('blocked', 5000); + }, + isStale: () => true, + }); + + await expect(fv.resolve()).rejects.toThrow('blocked'); + vi.advanceTimersByTime(4999); + await expect(fv.resolve()).rejects.toThrow('blocked'); + expect(blockedFetchCount).toBe(1); + + vi.advanceTimersByTime(1); + await expect(fv.resolve()).rejects.toThrow('blocked'); + expect(blockedFetchCount).toBe(2); + expect(fv.value).toBeUndefined(); + }); + + it('uses the configured retry delay for other errors', async () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + let failedFetchCount = 0; + const fv = new FetchedValue({ + fetch: async () => { + failedFetchCount++; + throw new Error('network failure'); + }, + isStale: () => true, + getRetryAfterMs: () => 5000, + }); + + await expect(fv.resolve()).rejects.toThrow('network failure'); + vi.advanceTimersByTime(4999); + await expect(fv.resolve()).rejects.toThrow('network failure'); + expect(failedFetchCount).toBe(1); + + vi.advanceTimersByTime(1); + await expect(fv.resolve()).rejects.toThrow('network failure'); + expect(failedFetchCount).toBe(2); + }); + + it('configured retry delays suppress retries without hiding failures behind a cached value', async () => { + vi.useFakeTimers(); + vi.setSystemTime(100); + let shouldFail = false; + let fetchCount = 0; + const fv = new FetchedValue({ + fetch: async () => { + fetchCount++; + if (shouldFail) { + throw new Error('signed out'); + } + return nextToken; + }, + isStale: () => true, + getRetryAfterMs: () => 5000, + }); + + await expect(fv.resolve()).resolves.toBe(nextToken); + shouldFail = true; + await expect(fv.resolve()).rejects.toThrow('signed out'); + await expect(fv.resolve()).rejects.toThrow('signed out'); + expect(fetchCount).toBe(2); + + vi.advanceTimersByTime(5000); + await expect(fv.resolve()).rejects.toThrow('signed out'); + expect(fetchCount).toBe(3); + }); + + it('force bypasses a blocked failure and invalidate clears it', async () => { + let shouldFail = true; + let blockedFetchCount = 0; const fv = new FetchedValue({ - fetch: async () => { throw new FetchBlockedError('blocked', 5000); }, + fetch: async () => { + blockedFetchCount++; + if (shouldFail) { + throw new FetchBlockedError('blocked', 5000); + } + return nextToken; + }, isStale: () => true, }); await expect(fv.resolve()).rejects.toThrow('blocked'); + shouldFail = false; + await expect(fv.resolve(true)).resolves.toBe(nextToken); + expect(blockedFetchCount).toBe(2); + + fv.invalidate(); expect(fv.value).toBeUndefined(); }); + it('invalidate prevents an older in-flight fetch from repopulating the cache', async () => { + let resolveFirst: ((value: TestToken) => void) | undefined; + const firstFetch = new Promise(resolve => resolveFirst = resolve); + const newToken = { value: 'token-2', expiresAt: Date.now() + 60_000 }; + let fetchCount = 0; + const fv = new FetchedValue({ + fetch: () => ++fetchCount === 1 ? firstFetch : Promise.resolve(newToken), + isStale: () => false, + }); + + const firstResolve = fv.resolve(); + fv.invalidate(); + await expect(fv.resolve()).resolves.toBe(newToken); + resolveFirst!(nextToken); + await expect(firstResolve).resolves.toBe(nextToken); + + expect(fv.value).toBe(newToken); + }); + it('dispose prevents further resolves', async () => { fetchedValue.dispose(); await expect(fetchedValue.resolve()).rejects.toThrow('disposed'); diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index d73ef51e8be80c..6af646ec933029 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -14,18 +14,31 @@ Open `http://127.0.0.1:3000`. Managed settings is mocked by default. Use the switch beside each endpoint tab to choose mock or passthrough. Presets apply immediately; status and JSON edits auto-save. -Point a client at the server with either: - -- **Code OSS from sources:** select **Apply Overrides**, reload, sign in, and run - **Developer: Sync Account Policy**. -- **Stable, Insiders, CLI, or another client:** configure the system proxy - mapping shown for the selected endpoint and copy the VS Code `http.proxy` - setting. - -If no request appears in **Live Requests**, use **Clear Policy Cache**. A fresh -managed-settings cache entry can prevent the client from making a request for up -to one hour. Then run **Developer: Restart Local Agent Host** to force a new SDK -policy resolution. +The GUI opens on the **Policies** workspace. Select **Setup** in the header to +open a modal that guides you through either connection method: + +- **System proxy (recommended):** works with Code OSS, Stable, Insiders, Copilot + CLI, and SDK/runtime clients. The page recommends Proxyman on macOS and + provides a **Map Remote** rule. VS Code normally uses the system proxy; the + `http.proxy` setting is available as an optional fallback when explicit client + configuration is needed. +- **Code OSS overrides:** the quicker option for Code OSS from this checkout. + Select **Apply Overrides**, reload, and sign in. This option does not redirect + SDK/runtime requests. + +After connecting, open the VS Code Command Palette and run **> Developer: Sync +Account Policy**. To refresh the policy used by Local Agent Host, also run +**> Developer: Restart Local Agent Host**. + +The Setup dialog checks Code OSS overrides directly. It tests the system proxy by +sending a request without credentials to the managed settings URL and confirming +that the response came from this local server. It does not inspect Proxyman or +macOS proxy configuration. The test runs automatically, and the global header +always shows a green or red connection indicator. + +If no real request appears in **Live Requests**, use **Clear Policy Cache**. A +fresh managed-settings cache entry can prevent the client from making a request +for up to one hour. Then run the commands above again. Other Copilot clients share that cache. For an isolated run, start both the server and Code OSS with the same temporary cache home: diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index 8994bb99d0346c..1765d2a0ae5f21 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -30,6 +30,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; wired: boolean; baseUrl?: string; upstream?: string; + overridesPath?: string; } interface LogEntry { @@ -83,6 +84,8 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; editorText: string; } + type SetupMethod = 'proxy' | 'overrides'; + const $ = (id: string): HTMLElement => document.getElementById(id)!; const tabs = $('tabs'); const editor = $('editor') as HTMLTextAreaElement; @@ -92,7 +95,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; const endpointMeta = $('endpoint-meta'); const editorStatus = $('editor-status'); const saveStateEl = $('save-state'); - const wiredStatusEl = $('wired-status'); + const setupDialog = $('setup-dialog') as HTMLDialogElement; const vscodeProxySettings = '{\n\t"http.proxy": "http://localhost:9090"\n}'; let endpoints: Endpoint[] = []; @@ -100,8 +103,10 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; const drafts: Record = {}; let schema: JsonSchema | null = null; let overridesWired = false; + let proxyVerified = false; let proxyBaseUrl = ''; let proxyUpstream = ''; + let proxyCheckInFlight = false; let stateUpdateQueue: Promise = Promise.resolve(); const pendingSaves = new Map>(); @@ -441,19 +446,111 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; function renderWired(state: ServerState): void { overridesWired = state.wired; - wiredStatusEl.textContent = state.wired ? 'Applied \u2713' : 'Not applied'; - wiredStatusEl.dataset.kind = state.wired ? 'ok' : ''; + const status = $('override-status'); + status.textContent = state.wired ? 'Applied \u2713' : 'Not applied'; + status.dataset.state = state.wired ? 'ready' : 'pending'; const action = $('overrides-action'); action.textContent = state.wired ? 'Restore Original' : 'Apply Overrides'; - action.className = `${state.wired ? 'btn-secondary' : 'btn-primary'} btn-full`; + action.className = state.wired ? 'btn-secondary' : 'btn-primary'; + updateReadiness(); } function renderProxy(): void { - const endpoint = activeEndpoint(); + const endpoint = endpoints.find(candidate => candidate.id === 'managedSettings') ?? endpoints[0]; $('map-from').textContent = endpoint && proxyUpstream ? `${proxyUpstream}${endpoint.path}` : ''; $('map-to').textContent = endpoint && proxyBaseUrl ? `${proxyBaseUrl}${endpoint.path}` : ''; } + function selectSetupMethod(method: SetupMethod): void { + for (const candidate of ['proxy', 'overrides'] as const) { + const selected = candidate === method; + $(`${candidate}-method`).dataset.selected = String(selected); + $(`${candidate}-method-steps`).toggleAttribute('inert', !selected); + ($(`setup-method-${candidate}`) as HTMLInputElement).checked = selected; + } + } + + function updateReadiness(): void { + const connectionReady = proxyVerified || overridesWired; + const globalStatus = $('global-connection-status'); + globalStatus.dataset.state = connectionReady ? 'ready' : proxyCheckInFlight ? 'checking' : 'error'; + $('global-connection-label').textContent = proxyVerified + ? 'System proxy connected' + : overridesWired ? 'Code OSS overrides active' : proxyCheckInFlight ? 'Checking connection\u2026' : 'No connection detected'; + } + + function renderProxyStatus(state: 'checking' | 'ready' | 'pending', message: string, detail: string): void { + const status = $('proxy-status'); + status.dataset.state = state; + status.textContent = message; + $('proxy-check-detail').textContent = detail; + } + + async function checkProxy(): Promise { + if (proxyCheckInFlight) { + return; + } + const endpoint = endpoints.find(candidate => candidate.id === 'managedSettings') ?? endpoints[0]; + if (!endpoint || !proxyUpstream) { + proxyVerified = false; + renderProxyStatus('pending', 'Not detected', 'Could not determine the managed settings URL. Reload the page and try again.'); + updateReadiness(); + return; + } + + const wasVerified = proxyVerified; + const checkStartedAt = Date.now(); + proxyCheckInFlight = true; + updateReadiness(); + if (!wasVerified) { + renderProxyStatus('checking', 'Checking\u2026', 'Testing the managed settings URL without sending credentials.'); + } + let nextState: 'ready' | 'pending'; + let nextMessage: string; + let nextDetail: string; + try { + const probe = new URL(endpoint.path, proxyUpstream); + probe.searchParams.set('mockPolicySetupProbe', crypto.randomUUID()); + const response = await fetch(probe, { cache: 'no-store', credentials: 'omit' }); + proxyVerified = response.headers.get('X-Mock-Policy-Server') === 'true'; + nextState = proxyVerified ? 'ready' : 'pending'; + nextMessage = proxyVerified ? 'Connected' : 'Not detected'; + nextDetail = proxyVerified + ? 'Requests to the managed settings URL are reaching this server.' + : 'The test request did not reach this server. Check that your system proxy and redirect rule are enabled.'; + } catch { + proxyVerified = false; + nextState = 'pending'; + nextMessage = 'Not detected'; + nextDetail = 'The test request did not reach this server. Check your system proxy, redirect rule, and HTTPS certificate trust.'; + } + + if (!wasVerified) { + const remainingCheckingTime = 600 - (Date.now() - checkStartedAt); + if (remainingCheckingTime > 0) { + await new Promise(resolve => setTimeout(resolve, remainingCheckingTime)); + } + } + renderProxyStatus(nextState, nextMessage, nextDetail); + proxyCheckInFlight = false; + updateReadiness(); + } + + function openSetupDialog(): void { + if (!setupDialog.open) { + setupDialog.showModal(); + } + $('setup-nav').setAttribute('aria-expanded', 'true'); + } + + function syncSetupDialog(): void { + if (location.hash === '#setup') { + openSetupDialog(); + } else if (setupDialog.open) { + setupDialog.close(); + } + } + function applyState(state: ServerState): void { endpoints = state.endpoints; proxyBaseUrl = state.baseUrl ?? ''; @@ -750,6 +847,18 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } + async function clearPolicyCache(): Promise { + try { + const result = await api('/api/cache', { method: 'DELETE' }); + const count = result.cleared.reduce((total, item) => total + item.files, 0); + toast(result.cleared.length === 0 + ? 'No managed-settings cache found \u2014 nothing to clear' + : `Cleared ${count} cached ${count === 1 ? 'entry' : 'entries'} \u2014 restart Local Agent Host to refetch`); + } catch (e) { + toast(`Could not clear cache: ${e instanceof Error ? e.message : String(e)}`, true); + } + } + async function copy(text: string, button: HTMLElement): Promise { try { await navigator.clipboard.writeText(text); @@ -793,6 +902,23 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; $('copy-proxy-settings').addEventListener('click', e => { copy(vscodeProxySettings, e.currentTarget as HTMLElement); }); + for (const method of ['proxy', 'overrides'] as const) { + $(`setup-method-${method}`).addEventListener('change', () => selectSetupMethod(method)); + } + $('setup-nav').addEventListener('click', openSetupDialog); + $('close-setup').addEventListener('click', () => setupDialog.close()); + $('policies-nav').addEventListener('click', () => { + if (setupDialog.open) { + setupDialog.close(); + } + }); + setupDialog.addEventListener('close', () => { + $('setup-nav').setAttribute('aria-expanded', 'false'); + if (location.hash === '#setup') { + history.replaceState(null, '', '#policies'); + } + }); + window.addEventListener('hashchange', syncSetupDialog); $('schema-toggle').addEventListener('click', toggleSchemaSection); $('hydrate-schema').addEventListener('click', () => { if (!schema) { @@ -811,17 +937,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; void save(); setStatus('Generated an example from the schema.', 'ok'); }); - $('clear-cache').addEventListener('click', async () => { - try { - const result = await api('/api/cache', { method: 'DELETE' }); - const count = result.cleared.reduce((total, item) => total + item.files, 0); - toast(result.cleared.length === 0 - ? 'No managed-settings cache found — nothing to clear' - : `Cleared ${count} cached ${count === 1 ? 'entry' : 'entries'} — restart Local Agent Host to refetch`); - } catch (e) { - toast(`Could not clear cache: ${e instanceof Error ? e.message : String(e)}`, true); - } - }); + $('clear-cache').addEventListener('click', () => { void clearPolicyCache(); }); $('clear-log').addEventListener('click', async () => { try { await api('/api/log', { method: 'DELETE' }); @@ -838,11 +954,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; try { const state = await api('/api/state'); + selectSetupMethod(state.wired ? 'overrides' : 'proxy'); applyState(state); if (endpoints.length) { selectEndpoint(endpoints[0].id); } + syncSetupDialog(); } catch (e) { + selectSetupMethod('proxy'); // Fall back to the shared endpoint definitions so the GUI still shows // what exists (read-only) rather than rendering a blank page. endpoints = MOCK_POLICY_ENDPOINTS.map(def => ({ ...def, status: def.presets[0]?.status ?? 200, body: def.presets[0]?.body ?? {} })); @@ -852,11 +971,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } setStatus(`Failed to load state: ${e instanceof Error ? e.message : String(e)}`, 'error'); toast('Cannot reach the server. Is it still running?', true); + syncSetupDialog(); } await loadSchema(); await refreshLog(); + await checkProxy(); setInterval(refreshLog, 2000); + setInterval(() => { void checkProxy(); }, 5000); } void init(); diff --git a/scripts/mock-policy-server/public/index.html b/scripts/mock-policy-server/public/index.html index 306c8f4e581858..3d80196fec873f 100644 --- a/scripts/mock-policy-server/public/index.html +++ b/scripts/mock-policy-server/public/index.html @@ -15,108 +15,212 @@
-

Mock Policy Server

- +
+

Mock Policy Server

+

Test Copilot policy responses against a local server.

+
+
+ + +
-
-
-
- +
+ +
+
- -
-

- -
- - +
+
+
+

Connect a client

+

Route policy requests to this server

+

Select a connection method below to enable its steps. You can switch methods at any time.

+
-
- - -

-
+
+ Connection method +
+ + +
+
+
+ + +
+
Not applied
+ +
    +
  1. + +
    +

    Apply the local endpoint URLs

    +

    The server updates product.overrides.json and preserves any other top-level overrides.

    + +
    +
  2. +
  3. + +
    +

    Reload and request policy

    +

    Reload Code OSS, sign in, open the Command Palette, and run > Developer: Sync Account Policy.

    +

    If no request appears under Live Requests on the Policies page, clear the policy cache from the header and run the command again.

    +
    +
  4. +
+
+
-
-
- - +
+
+ +
+

Policy Editor

+
+
+
+
- -

- -
-
-
- +
diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css index 26f9f47c4ef69e..1fc5c4f4c3ae5e 100644 --- a/scripts/mock-policy-server/public/style.css +++ b/scripts/mock-policy-server/public/style.css @@ -19,6 +19,8 @@ --code-bg: #1b1b1b; --code-border: #3c3c3c; --focus: #4daafc; + --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent); + --ok-soft: color-mix(in srgb, var(--ok) 12%, transparent); } /* @@ -49,6 +51,17 @@ box-sizing: border-box; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; @@ -64,7 +77,7 @@ body { } header { - padding: 24px; + padding: 16px 24px; background: linear-gradient(135deg, var(--surface-alt) 0%, var(--surface) 100%); border-bottom: 1px solid var(--border); } @@ -73,42 +86,149 @@ header { max-width: 1400px; margin: 0 auto; display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; } h1 { - font-size: 24px; + font-size: 18px; font-weight: 600; - margin: 0 0 4px; - letter-spacing: -0.5px; + margin: 0; } h2 { + font-size: 24px; + font-weight: 600; + line-height: 1.2; + margin: 0; +} + +.brand p { + margin: 2px 0 0; + color: var(--text-secondary); font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; +} + +.header-actions, +.page-nav { + display: flex; + align-items: center; + gap: 8px; +} + +.global-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-left: 1px solid var(--border); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; +} + +.global-status[data-state='ready'] { + color: var(--text-secondary); +} + +.global-status[data-state='ready'] .status-indicator { + border-color: var(--ok); + background: var(--ok); + animation: status-detected 500ms ease-out; +} + +.global-status[data-state='error'] { + color: var(--text-secondary); +} + +.global-status[data-state='error'] .status-indicator { + border-color: var(--error); + background: var(--error); +} + +.global-status[data-state='checking'] .status-indicator { + border-color: var(--accent); + background: var(--accent); + animation: status-detecting 1s ease-in-out infinite; +} + +.page-nav { + padding: 2px; +} + +.setup-status-group { + display: inline-flex; + align-items: stretch; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--bg) 24%, transparent); +} + +.page-nav a, +.setup-trigger { + padding: 6px 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + font-weight: 600; + text-decoration: none; +} + +.page-nav a:hover, +.setup-trigger:hover { + color: var(--text); +} + +.page-nav a[aria-current='page'], +.setup-trigger[aria-expanded='true'] { + background: var(--surface-alt); + color: var(--text); +} + +.btn-header { + padding: 6px 8px; + border-color: transparent; + background: transparent; color: var(--text-secondary); - margin: 0; } -main { +.btn-header:hover { + border-color: var(--border); + background: var(--surface-alt); + color: var(--text); +} + +.app-main { + max-width: 1400px; + margin: 0 auto; + padding: 24px; +} + +.policies-layout { display: grid; grid-template-columns: 1fr 360px; gap: 20px; - padding: 24px; - max-width: 1400px; - margin: 0 auto; align-items: start; } @media (max-width: 900px) { - main { + .policies-layout { grid-template-columns: 1fr; } + + .header-content, + .header-actions { + align-items: stretch; + } + + .header-actions { + width: 100%; + justify-content: space-between; + } } .editor-panel { @@ -157,6 +277,446 @@ main { margin: 0; } +.section-header h2, +.schema-heading { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +/* --- Setup ----------------------------------------------------------------- */ + +.setup-dialog { + width: min(1280px, calc(100vw - 48px)); + max-width: none; + max-height: calc(100vh - 48px); + padding: 20px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); +} + +.setup-dialog::backdrop { + background: rgba(0, 0, 0, 0.62); +} + +.dialog-actions { + position: sticky; + top: -20px; + z-index: 2; + display: flex; + justify-content: flex-end; + padding: 0 0 12px; + background: var(--bg); +} + +.setup-page { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 1240px; + margin: 0 auto; +} + +.setup-intro { + padding: 0 0 4px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent-hover); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.setup-lede { + max-width: 640px; + margin: 10px 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.5; +} + +.status-indicator { + width: 8px; + height: 8px; + flex: none; + border: 1px solid var(--text-secondary); + border-radius: 50%; + background: transparent; +} + +.setup-methods { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(400px, 0.8fr); + gap: 20px; + align-items: start; +} + +.setup-method-picker { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.setup-method { + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.setup-method[data-selected='true'] { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); +} + +.setup-method[data-selected='false'] { + background: color-mix(in srgb, var(--surface) 72%, var(--bg)); +} + +.setup-method[data-selected='false'] .method-choice, +.setup-method[data-selected='false'] .setup-steps { + opacity: 0.42; +} + +.method-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); +} + +.method-choice { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 8px; + min-width: 0; +} + +.method-choice input { + width: 14px; + height: 14px; + margin: 6px 0 0; + accent-color: var(--accent); + cursor: pointer; +} + +.method-label { + min-width: 0; + cursor: pointer; +} + +.method-heading h3 { + margin: 4px 0 0; + font-size: 18px; + font-weight: 600; + line-height: 1.3; +} + +.method-recommendation { + display: block; + margin-top: 2px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; +} + +.method-heading p { + max-width: 640px; + margin: 6px 0 0; + color: var(--text-secondary); + line-height: 1.5; +} + +.method-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--focus); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.method-badge.secondary { + background: var(--surface-alt); + color: var(--text-secondary); +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-alt); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; +} + +.status-pill::before { + content: ''; + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: currentColor; +} + +.status-pill[data-state='ready'] { + border-color: color-mix(in srgb, var(--ok) 55%, var(--border)); + background: var(--ok-soft); + color: var(--ok); +} + +.status-pill[data-state='ready']::before { + animation: status-detected 500ms ease-out; +} + +.status-pill[data-state='pending'] { + border-color: color-mix(in srgb, var(--error) 55%, var(--border)); + background: color-mix(in srgb, var(--error) 10%, transparent); + color: var(--error); +} + +.status-pill[data-state='checking'] { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + background: var(--accent-soft); + color: var(--focus); +} + +.status-pill[data-state='checking']::before { + animation: status-detecting 1s ease-in-out infinite; +} + +.method-status { + display: flex; + flex: none; + flex-direction: column; + align-items: flex-end; + gap: 6px; + max-width: 280px; +} + +.method-status p { + margin: 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; + text-align: right; +} + +.setup-steps { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0 20px; + list-style: none; +} + +.setup-steps > li { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 12px; + padding: 20px 0; +} + +.setup-steps > li + li { + border-top: 1px solid var(--border); +} + +.step-number { + display: inline-flex; + align-items: flex-start; + justify-content: center; + width: 20px; + padding-top: 4px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; +} + +.step-content { + min-width: 0; +} + +.step-content h4 { + margin: 2px 0 4px; + font-size: 13px; + font-weight: 600; +} + +.step-content > p { + margin: 0 0 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +.mapping-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 8px; + align-items: end; + margin-bottom: 10px; +} + +.mapping-arrow { + padding-bottom: 10px; + color: var(--text-secondary); +} + +.code-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.code-field code.block { + min-height: 56px; +} + +.step-content details { + margin-top: 12px; + color: var(--text-secondary); +} + +.step-content summary { + width: fit-content; + cursor: pointer; + color: var(--text); + font-weight: 600; +} + +.step-content details ul { + margin: 8px 0 0; + padding-left: 20px; +} + +.step-content details p { + margin: 8px 0; +} + +.step-content details .code-field { + margin-bottom: 10px; +} + +.setup-steps.compact .step-content > p { + margin-bottom: 10px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@keyframes status-detecting { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.8); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes status-detected { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); + transform: scale(0.8); + } + + 55% { + box-shadow: 0 0 0 5px transparent; + transform: scale(1.25); + } + + 100% { + box-shadow: none; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .global-status[data-state] .status-indicator, + .status-pill[data-state]::before { + animation: none; + } +} + +@media (max-width: 1000px) { + .setup-methods { + grid-template-columns: 1fr; + } +} + +@media (max-width: 680px) { + header, + .app-main { + padding-inline: 16px; + } + + .header-actions { + flex-wrap: wrap; + align-items: center; + } + + .page-nav { + justify-content: flex-start; + } + + .setup-dialog { + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + padding: 16px; + } + + .dialog-actions { + top: -16px; + } + + .method-heading { + flex-direction: column; + } + + .method-status { + align-items: flex-start; + max-width: none; + } + + .method-status p { + text-align: left; + } + + .mapping-grid { + grid-template-columns: 1fr; + } + + .mapping-arrow { + display: none; + } +} + .info-button { position: relative; display: inline-flex; diff --git a/scripts/mock-policy-server/server.ts b/scripts/mock-policy-server/server.ts index d3bcd6bfa13c20..a7ae2c86e1a4f1 100644 --- a/scripts/mock-policy-server/server.ts +++ b/scripts/mock-policy-server/server.ts @@ -30,6 +30,8 @@ const DEFAULT_SCHEMA_SOURCE = resolveDefaultSchemaSource(); /** Real API that un-mocked requests are forwarded to. */ const DEFAULT_UPSTREAM = 'https://api.github.com'; const PORT = 3000; +const SETUP_PROBE_PARAM = 'mockPolicySetupProbe'; +const MOCK_SERVER_HEADER = 'X-Mock-Policy-Server'; const args = parseArgs(process.argv.slice(2)); const HOST = args.host || '127.0.0.1'; @@ -109,14 +111,24 @@ const server = http.createServer((req, res) => { return; } + // A credential-free browser probe to the upstream URL is redirected here + // by a correctly configured system proxy. Keep it out of the request log + // so setup checks are not mistaken for real client traffic. + const endpoint = endpoints.find(endpoint => pathname === endpoint.path); + if (endpoint && url.searchParams.has(SETUP_PROBE_PARAM)) { + setMockResponseHeaders(res); + if (req.method === 'OPTIONS' || req.method === 'GET') { + res.writeHead(204); + res.end(); + return; + } + } + // Mocked Copilot endpoints. Only these get permissive CORS, so the web // build (browser) of Code OSS can call them cross-origin. - const endpoint = endpoints.find(endpoint => pathname === endpoint.path); if (endpoint && state.get(endpoint.id)?.active) { const entry = state.get(endpoint.id)!; - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Editor-Version, Copilot-Runtime-Version'); + setMockResponseHeaders(res); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); @@ -136,6 +148,14 @@ const server = http.createServer((req, res) => { } }); +function setMockResponseHeaders(res: ServerResponse): void { + res.setHeader(MOCK_SERVER_HEADER, 'true'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Editor-Version, Copilot-Runtime-Version'); + res.setHeader('Access-Control-Expose-Headers', MOCK_SERVER_HEADER); +} + /** Same-origin control API used by the GUI. */ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: string): void { if (pathname === '/api/state' && req.method === 'GET') { diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 3f154bad0e54f2..773fc2a9ac41a0 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -69,6 +69,16 @@ export interface IPolicyData { * `enabledPlugins`, `extraKnownMarketplaces`) are carried as canonical JSON strings. */ readonly managedSettings?: ManagedSettingsData; + + /** + * Whether at least one managed-settings delivery channel currently supplies a setting — i.e. + * the user is governed by GitHub Copilot managed settings at all, independent of which keys + * were set. + * + * Unlike {@link managedSettings}, this is not projected onto the keys VS Code declares, so it + * also reflects runtime-owned keys VS Code never reads. + */ + readonly managedSettingsActive?: boolean; } export interface ICopilotTokenInfo { diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 8cf3c9c134d770..80b9d4ede3e90f 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -7,7 +7,7 @@ import * as nls from '../../../nls.js'; import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { AgentHostConfigurationSyncScope, ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationPropertySchema, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; -import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue } from '../../policy/common/copilotManagedSettings.js'; +import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue, thirdPartyAgentEnabledValue } from '../../policy/common/copilotManagedSettings.js'; import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { @@ -254,7 +254,7 @@ configurationRegistry.registerConfiguration({ name: 'Claude3PIntegration', category: PolicyCategory.InteractiveSession, minimumVersion: '1.113', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + value: thirdPartyAgentEnabledValue, localization: { description: { key: 'chat.agentHost.claudeAgent.enabled.policy', @@ -288,7 +288,7 @@ configurationRegistry.registerConfiguration({ name: 'Codex3PIntegration', category: PolicyCategory.InteractiveSession, minimumVersion: '1.126', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + value: thirdPartyAgentEnabledValue, localization: { description: { key: 'chat.agentHost.codexAgent.enabled.policy', diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts index 692088bc49dff7..c9ec787e1862fa 100644 --- a/src/vs/platform/agentHost/common/copilotCliConfig.ts +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -30,6 +30,8 @@ export const enum CopilotCliConfigKey { ReasoningEffortOverride = 'reasoningEffortOverride', /** Enable concise reasoning summaries for supported models. Off by default. */ ReasoningSummary = 'reasoningSummary', + /** Let the Auto router score prior turns instead of the latest message alone. Off by default. */ + MultiTurnContextRouting = 'multiTurnContextRouting', /** Per-model capability overrides (family aliases) keyed by model id. */ ModelCapabilityOverrides = 'modelCapabilityOverrides', } @@ -55,6 +57,8 @@ export const AgentHostReasoningEffortOverrideSettingId = 'chat.agentHost.copilot export const AgentHostReasoningSummaryEnabledSettingId = 'chat.agentHost.copilot.reasoningSummary.enabled'; +export const AgentHostMultiTurnContextRoutingEnabledSettingId = 'chat.agentHost.copilot.multiTurnContextRouting.enabled'; + export const AgentHostModelCapabilityOverridesSettingId = 'chat.agentHost.modelCapabilityOverrides'; export const AgentHostCopilotModelCapabilityOverridesSettingId = 'chat.agentHost.copilot.modelCapabilityOverrides'; @@ -164,6 +168,12 @@ export const copilotCliConfigSchema = createSchema({ description: localize('agentHost.config.reasoningSummary.description', "When enabled, requests concise reasoning summaries for supported Copilot SDK sessions."), default: false, }), + [CopilotCliConfigKey.MultiTurnContextRouting]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.multiTurnContextRouting.title', "Auto Multi-Turn Context Routing"), + description: localize('agentHost.config.multiTurnContextRouting.description', "When enabled, Auto model selection sends prior user messages to the router so it scores the conversation so far instead of the latest message alone."), + default: false, + }), [CopilotCliConfigKey.ModelCapabilityOverrides]: schemaProperty({ type: 'object', title: localize('agentHost.config.modelCapabilityOverrides.title', "Model Capability Overrides"), diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index f1917ffbcc8935..a0fc9e7fb109c7 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -470,6 +470,12 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private async _computeTurnChangeset(session: ProtocolURI, turnId: string, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise { const turnUri = this._stateManager.registerChangeset(buildTurnChangesetUri(session, turnId)); + if (this._stateManager.getChangesetState(turnUri)?.status !== ChangesetStatus.Computing) { + this._stateManager.dispatchServerAction(turnUri, { + type: ActionType.ChangesetStatusChanged, + status: ChangesetStatus.Computing, + }); + } const stopWatch = StopWatch.create(); let outcome: TurnChangesetOutcome = 'error'; let result: ITurnDiffResult | undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2c709c0e36906a..ce52c65745eecb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -973,6 +973,10 @@ export class CopilotAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) ?? DEFAULT_COPILOT_RUBBER_DUCK_ENABLED; } + private _isMultiTurnContextRoutingEnabled(): boolean { + return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.MultiTurnContextRouting) === true; + } + private _getCopilotSdkLogLevelSetting(): CopilotSdkLogLevelSetting { return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.CopilotSdkLogLevel) ?? 'info'; } @@ -1001,6 +1005,7 @@ export class CopilotAgent extends Disposable implements IAgent { return new CopilotAgentStartupConfig( this._isSessionSyncEnabled(), this._isRubberDuckEnabled(), + this._isMultiTurnContextRoutingEnabled(), this._getCopilotSdkLogLevelSetting(), this._getEnterpriseHost(), this._isSystemProxyEnabled(), @@ -1935,6 +1940,17 @@ export class CopilotAgent extends Disposable implements IAgent { delete env['RUBBER_DUCK_AGENT']; } + // Let the Auto router score prior user messages instead of the latest + // message alone. `MULTI_TURN_CONTEXT_ROUTING` is the runtime's local + // override for the matching ExP flag, and only takes effect on top of + // the single-call Auto endpoint that `createCopilotCliEnvironment` + // already opts into. + if (startupConfig.multiTurnContextRouting) { + env['MULTI_TURN_CONTEXT_ROUTING'] = 'true'; + } else { + delete env['MULTI_TURN_CONTEXT_ROUTING']; + } + // Resolve the CLI entry point and native SDK binaries from node_modules. // In the desktop app these live next to the ASAR archive in // `node_modules.asar.unpacked` (the `@github/copilot-` CLI and diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts index c3cb3dc1ed3237..8cedc2eaa10369 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts @@ -11,6 +11,7 @@ export class CopilotAgentStartupConfig { constructor( readonly sessionSync: boolean, readonly rubberDuck: boolean, + readonly multiTurnContextRouting: boolean, readonly copilotSdkLogLevel: CopilotSdkLogLevelSetting, readonly enterpriseHost: string | undefined, readonly systemProxy: boolean, diff --git a/src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts b/src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts index 5d6c8938529b35..bf7bb75fc78474 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts @@ -24,5 +24,9 @@ export function createCopilotCliEnvironment(environment: NodeJS.ProcessEnv = pro env['COPILOT_MCP_APPS'] = 'true'; env[AiAgentEnvVar] = AiAgentEnvValue; env['AUTO_APPROVAL'] = 'true'; + // Resolve Auto mode through the CLI's single-call `POST /auto` endpoint. The + // runtime gates this on an ExP flag whose local override is the flag name + // itself, so VS Code opts its whole population in rather than splitting it. + env['AUTO_V2_ENDPOINT'] = 'true'; return env; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 57d2732b41ba36..e362c15476d86a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -1275,6 +1275,80 @@ class RecordingLogService extends NullLogService { } } +suite('AgentHostChangesetService - turn changeset lifecycle', () => { + + const disposables = new DisposableStore(); + const sessionStr = AgentSession.uri('mock', 'session-turn-lifecycle').toString(); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks a ready turn changeset as computing until recomputation completes', async () => { + const recomputeGate = new DeferredPromise(); + let computeCount = 0; + const git = createNoopGitService(); + git.getRepositoryRoot = async wd => URI.parse(wd.toString()); + git.computeFileDiffsBetweenRefs = async () => { + computeCount++; + if (computeCount > 1) { + await recomputeGate.p; + } + return []; + }; + const checkpoint: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + getTurnCheckpointPair: async () => ({ parent: 'parent', current: 'current' }), + }; + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const diffService = new TestDiffComputeService(); + class TestableChangesetService extends AgentHostChangesetService { + protected override _createDiffComputeService() { + return diffService; + } + } + const svc = disposables.add(new TestableChangesetService( + stateManager, + new NullLogService(), + createSessionDataService(new TestSessionDatabase()), + git, + checkpoint, + disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), + createSubscriptionService(), + NULL_REVIEW_SERVICE, + NullTelemetryService, + )); + stateManager.createSession({ + resource: sessionStr, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + workingDirectories: ['file:///repo'], + }); + const turnUri = await svc.computeTurnChangeset(sessionStr, 'turn-1'); + + const recompute = svc.computeTurnChangeset(sessionStr, 'turn-1'); + const whileRecomputing = stateManager.getChangesetState(turnUri)?.status; + recomputeGate.complete(); + await recompute; + + assert.deepStrictEqual({ + whileRecomputing, + afterRecompute: stateManager.getChangesetState(turnUri), + }, { + whileRecomputing: ChangesetStatus.Computing, + afterRecompute: { + status: ChangesetStatus.Ready, + files: [], + }, + }); + }); +}); + /** * Multi-root turn changeset aggregation (AC-2). A separate top-level suite so * these run against the current service (the older `AgentHostChangesetService` diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 92fac546cb4ff6..53e0e5df4a7b0e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3719,6 +3719,41 @@ suite('CopilotAgent', () => { } }); + test('enables the auto v2 endpoint always and multi-turn context routing only when configured', async () => { + const defaultClient = new TestCopilotClient([]); + const { agent: defaultAgent } = createTestAgentContext(disposables, { copilotClient: defaultClient }); + try { + await defaultAgent.listChatsToMigrate(); + + const routingClient = new TestCopilotClient([]); + const { agent: routingAgent } = createTestAgentContext(disposables, { + copilotClient: routingClient, + rootConfig: { [CopilotCliConfigKey.MultiTurnContextRouting]: true }, + }); + try { + await routingAgent.listChatsToMigrate(); + + const defaultEnv = getCreatedClientOptions(defaultAgent).at(-1)?.env; + const routingEnv = getCreatedClientOptions(routingAgent).at(-1)?.env; + assert.deepStrictEqual({ + defaultAutoV2: defaultEnv?.['AUTO_V2_ENDPOINT'], + defaultMultiTurn: defaultEnv?.['MULTI_TURN_CONTEXT_ROUTING'], + routingAutoV2: routingEnv?.['AUTO_V2_ENDPOINT'], + routingMultiTurn: routingEnv?.['MULTI_TURN_CONTEXT_ROUTING'], + }, { + defaultAutoV2: 'true', + defaultMultiTurn: undefined, + routingAutoV2: 'true', + routingMultiTurn: 'true', + }); + } finally { + await disposeAgent(routingAgent); + } + } finally { + await disposeAgent(defaultAgent); + } + }); + test('enables the built-in GitHub MCP server by default and removes its environment variable when disabled', async () => { const enabledClient = new TestCopilotClient([]); const { agent: enabledAgent } = createTestAgentContext(disposables, { copilotClient: enabledClient }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts index a217459a26b06b..2bcbccd625b96b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts @@ -11,9 +11,9 @@ suite('CopilotAgentStartupConfig', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('compares and describes startup configuration changes', () => { - const previous = new CopilotAgentStartupConfig(false, true, 'info', undefined, true, true, {}); - const same = new CopilotAgentStartupConfig(false, true, 'info', undefined, true, true, {}); - const changed = new CopilotAgentStartupConfig(true, true, 'trace', 'github.example.com', false, false, { deny: ['shell(*)'] }); + const previous = new CopilotAgentStartupConfig(false, true, false, 'info', undefined, true, true, {}); + const same = new CopilotAgentStartupConfig(false, true, false, 'info', undefined, true, true, {}); + const changed = new CopilotAgentStartupConfig(true, true, true, 'trace', 'github.example.com', false, false, { deny: ['shell(*)'] }); assert.deepStrictEqual({ same: same.equals(previous), @@ -24,7 +24,7 @@ suite('CopilotAgentStartupConfig', () => { same: true, changed: false, proxyTargetChanged: true, - description: 'sessionSync=true, copilotSdkLogLevel=trace, enterpriseHost=github.example.com, systemProxy=false, githubMcpServer=false, managedSettingsPermissions', + description: 'sessionSync=true, multiTurnContextRouting=true, copilotSdkLogLevel=trace, enterpriseHost=github.example.com, systemProxy=false, githubMcpServer=false, managedSettingsPermissions', }); }); }); 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 e77b44f4a90157..bd39e8cb9cac2d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -16,6 +16,23 @@ When a valid E2E scenario exposes a gap: Capability skips are tracked separately from suspected bugs. A provider that does not advertise a capability is expected to skip positive-path tests for that capability. +### Binary writes to client-hosted files are corrupted + +An agent host can address files that live on a connected client and send symmetric AHP filesystem operations back to that client. When the host writes binary content this way, bytes that are not valid UTF-8 are replaced before they reach the client, so images and other binary files can be corrupted. + +- Test: `client-hosted resourceWrite decodes base64 content`. +- Scope: conformance reference provider on all platforms. +- Expected: a base64 `resourceWrite` to a `vscode-agent-client:` URI preserves every decoded byte when the host routes the write back to the owning client. +- Observed: bytes such as `0xff` and `0xfe` reach the client as UTF-8 replacement characters (`0xef 0xbf 0xbd`). +- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`. +- Reproduce: + + ```bash + AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \ + --grep "client-hosted resourceWrite decodes base64 content" + ``` + ### Duplicate session creation is accepted A client can retry session creation with a URI that already identifies a live session. The host accepts the duplicate request instead of reporting that the resource already exists, so clients cannot distinguish an idempotent retry from an accidental collision and a provider may be asked to create conflicting backing state. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 55a0d97ad22eb7..1c1c7c278918d9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -104,7 +104,8 @@ The residual case is `providerHostOnlyTest(...)`: per-provider, but no model tra | `conformance/` | The conformance-tier entry point. Registered once; names a reference provider. | | `providers/` | Deterministic provider entry points and provider-specific scenarios. Live Codex scenarios are isolated in `codexAgentHostLive.integrationTest.ts`. | | `suites/` | Scenario modules, each of which may contribute to either tier. Add new scenarios to the closest existing suite; add a suite module when a new behavior area emerges. | -| `suites/clientFilesystemSuite.ts` | The `resource*` family in both directions, including the host's reverse requests for client-side files. | +| `suites/clientFilesystemSuite.ts` | Client-to-host `resource*` operations and resource-watch behavior. | +| `suites/clientHostedFilesystemSuite.ts` | Host-to-client `resource*` operations against client-hosted files. | | `harness/` | Record/replay, AHP snapshots, shared turn drivers, and server lifecycle. | | `harness/agentHostTarget.ts` | The portability seam: the only code that knows how to launch a concrete AHP implementation. | | `captures/*.yaml` | Committed model fixtures, plus one shared strict empty fixture for tests that declare no model traffic. | 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 dde6dc29b04027..555105835594eb 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 @@ -22,9 +22,9 @@ ] }, "actions": { - "covered": 75, + "covered": 76, "total": 85, - "percentage": 88.23, + "percentage": 89.41, "uncovered": [ "changeset/fileRemoved", "changeset/fileSet", @@ -33,7 +33,6 @@ "chat/toolCallAuthRequired", "chat/toolCallAuthResolved", "session/activityChanged", - "session/customizationRemoved", "session/defaultChatChanged", "terminal/commandDetectionAvailable" ] 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 f247d9952b854f..5922c51fa9851c 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": 86462, - "total": 114066, - "percentage": 75.79 + "covered": 90860, + "total": 121106, + "percentage": 75.02 }, "branches": { - "covered": 10023, - "total": 14969, - "percentage": 66.95 + "covered": 10705, + "total": 15973, + "percentage": 67.01 }, "functions": { - "covered": 3239, - "total": 4529, - "percentage": 71.51 + "covered": 3433, + "total": 4852, + "percentage": 70.75 }, "lines": { - "covered": 86462, - "total": 114066, - "percentage": 75.79 + "covered": 90860, + "total": 121106, + "percentage": 75.02 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1152, - "total": 1162, - "percentage": 99.13 + "covered": 1196, + "total": 1215, + "percentage": 98.43 }, "branches": { "covered": 27, @@ -49,13 +49,13 @@ }, "functions": { "covered": 10, - "total": 12, - "percentage": 83.33 + "total": 15, + "percentage": 66.66 }, "lines": { - "covered": 1152, - "total": 1162, - "percentage": 99.13 + "covered": 1196, + "total": 1215, + "percentage": 98.43 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -104,24 +104,24 @@ }, "src/vs/platform/agentHost/common/agentHostByokLm.ts": { "statements": { - "covered": 214, - "total": 221, - "percentage": 96.83 + "covered": 223, + "total": 230, + "percentage": 96.95 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 2, - "percentage": 0 + "covered": 1, + "total": 3, + "percentage": 33.33 }, "lines": { - "covered": 214, - "total": 221, - "percentage": 96.83 + "covered": 223, + "total": 230, + "percentage": 96.95 } }, "src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts": { @@ -258,24 +258,24 @@ }, "src/vs/platform/agentHost/common/agentHostConfigurationSync.ts": { "statements": { - "covered": 72, - "total": 148, - "percentage": 48.64 + "covered": 89, + "total": 191, + "percentage": 46.59 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { "covered": 0, - "total": 8, + "total": 11, "percentage": 0 }, "lines": { - "covered": 72, - "total": 148, - "percentage": 48.64 + "covered": 89, + "total": 191, + "percentage": 46.59 } }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { @@ -322,26 +322,48 @@ "percentage": 90.78 } }, + "src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts": { + "statements": { + "covered": 30, + "total": 30, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 30, + "total": 30, + "percentage": 100 + } + }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { "statements": { - "covered": 353, + "covered": 403, "total": 649, - "percentage": 54.39 + "percentage": 62.09 }, "branches": { - "covered": 23, - "total": 35, - "percentage": 65.71 + "covered": 38, + "total": 56, + "percentage": 67.85 }, "functions": { - "covered": 10, + "covered": 16, "total": 24, - "percentage": 41.66 + "percentage": 66.66 }, "lines": { - "covered": 353, + "covered": 403, "total": 649, - "percentage": 54.39 + "percentage": 62.09 } }, "src/vs/platform/agentHost/common/agentHostFileSystemService.ts": { @@ -373,9 +395,9 @@ "percentage": 95.08 }, "branches": { - "covered": 16, - "total": 20, - "percentage": 80 + "covered": 17, + "total": 21, + "percentage": 80.95 }, "functions": { "covered": 5, @@ -410,11 +432,55 @@ "percentage": 100 } }, + "src/vs/platform/agentHost/common/agentHostManagedRules.ts": { + "statements": { + "covered": 83, + "total": 151, + "percentage": 54.96 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 7, + "percentage": 0 + }, + "lines": { + "covered": 83, + "total": 151, + "percentage": 54.96 + } + }, "src/vs/platform/agentHost/common/agentHostManagedSettings.ts": { "statements": { - "covered": 44, - "total": 80, - "percentage": 55 + "covered": 186, + "total": 306, + "percentage": 60.78 + }, + "branches": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 10, + "percentage": 20 + }, + "lines": { + "covered": 186, + "total": 306, + "percentage": 60.78 + } + }, + "src/vs/platform/agentHost/common/agentHostResourceService.ts": { + "statements": { + "covered": 156, + "total": 161, + "percentage": 96.89 }, "branches": { "covered": 2, @@ -422,14 +488,14 @@ "percentage": 100 }, "functions": { - "covered": 1, - "total": 5, - "percentage": 20 + "covered": 0, + "total": 1, + "percentage": 0 }, "lines": { - "covered": 44, - "total": 80, - "percentage": 55 + "covered": 156, + "total": 161, + "percentage": 96.89 } }, "src/vs/platform/agentHost/common/agentHostReviewService.ts": { @@ -456,14 +522,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 711, - "total": 807, - "percentage": 88.1 + "covered": 759, + "total": 852, + "percentage": 89.08 }, "branches": { - "covered": 49, - "total": 68, - "percentage": 72.05 + "covered": 48, + "total": 64, + "percentage": 75 }, "functions": { "covered": 15, @@ -471,9 +537,9 @@ "percentage": 68.18 }, "lines": { - "covered": 711, - "total": 807, - "percentage": 88.1 + "covered": 759, + "total": 852, + "percentage": 89.08 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -500,31 +566,31 @@ }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 85, - "total": 106, - "percentage": 80.18 + "covered": 110, + "total": 141, + "percentage": 78.01 }, "branches": { - "covered": 8, - "total": 19, - "percentage": 42.1 + "covered": 10, + "total": 31, + "percentage": 32.25 }, "functions": { - "covered": 5, - "total": 7, - "percentage": 71.42 + "covered": 7, + "total": 9, + "percentage": 77.77 }, "lines": { - "covered": 85, - "total": 106, - "percentage": 80.18 + "covered": 110, + "total": 141, + "percentage": 78.01 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { "statements": { - "covered": 36, - "total": 48, - "percentage": 75 + "covered": 37, + "total": 49, + "percentage": 75.51 }, "branches": { "covered": 0, @@ -537,9 +603,9 @@ "percentage": 0 }, "lines": { - "covered": 36, - "total": 48, - "percentage": 75 + "covered": 37, + "total": 49, + "percentage": 75.51 } }, "src/vs/platform/agentHost/common/agentHostUri.ts": { @@ -586,6 +652,28 @@ "percentage": 100 } }, + "src/vs/platform/agentHost/common/agentMerge.ts": { + "statements": { + "covered": 244, + "total": 438, + "percentage": 55.7 + }, + "branches": { + "covered": 3, + "total": 27, + "percentage": 11.11 + }, + "functions": { + "covered": 2, + "total": 14, + "percentage": 14.28 + }, + "lines": { + "covered": 244, + "total": 438, + "percentage": 55.7 + } + }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { "statements": { "covered": 38, @@ -676,9 +764,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1016, - "total": 1181, - "percentage": 86.02 + "covered": 1082, + "total": 1249, + "percentage": 86.62 }, "branches": { "covered": 9, @@ -687,13 +775,13 @@ }, "functions": { "covered": 1, - "total": 8, - "percentage": 12.5 + "total": 9, + "percentage": 11.11 }, "lines": { - "covered": 1016, - "total": 1181, - "percentage": 86.02 + "covered": 1082, + "total": 1249, + "percentage": 86.62 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { @@ -725,9 +813,9 @@ "percentage": 80.07 }, "branches": { - "covered": 29, - "total": 39, - "percentage": 74.35 + "covered": 25, + "total": 35, + "percentage": 71.42 }, "functions": { "covered": 11, @@ -874,9 +962,9 @@ }, "src/vs/platform/agentHost/common/codexSessionConfigKeys.ts": { "statements": { - "covered": 108, - "total": 114, - "percentage": 94.73 + "covered": 111, + "total": 119, + "percentage": 93.27 }, "branches": { "covered": 8, @@ -885,13 +973,13 @@ }, "functions": { "covered": 3, - "total": 3, - "percentage": 100 + "total": 4, + "percentage": 75 }, "lines": { - "covered": 108, - "total": 114, - "percentage": 94.73 + "covered": 111, + "total": 119, + "percentage": 93.27 } }, "src/vs/platform/agentHost/common/commandLineHelpers.ts": { @@ -1050,24 +1138,24 @@ }, "src/vs/platform/agentHost/common/githubEndpoints.ts": { "statements": { - "covered": 100, + "covered": 106, "total": 130, - "percentage": 76.92 + "percentage": 81.53 }, "branches": { - "covered": 3, - "total": 10, - "percentage": 30 + "covered": 4, + "total": 14, + "percentage": 28.57 }, "functions": { - "covered": 3, + "covered": 4, "total": 4, - "percentage": 75 + "percentage": 100 }, "lines": { - "covered": 100, + "covered": 106, "total": 130, - "percentage": 76.92 + "percentage": 81.53 } }, "src/vs/platform/agentHost/common/githubIssueReferences.ts": { @@ -1114,6 +1202,28 @@ "percentage": 59.37 } }, + "src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts": { + "statements": { + "covered": 57, + "total": 90, + "percentage": 63.33 + }, + "branches": { + "covered": 3, + "total": 12, + "percentage": 25 + }, + "functions": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "lines": { + "covered": 57, + "total": 90, + "percentage": 63.33 + } + }, "src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts": { "statements": { "covered": 146, @@ -1158,6 +1268,28 @@ "percentage": 83.01 } }, + "src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts": { + "statements": { + "covered": 31, + "total": 31, + "percentage": 100 + }, + "branches": { + "covered": 3, + "total": 5, + "percentage": 60 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 31, + "total": 31, + "percentage": 100 + } + }, "src/vs/platform/agentHost/common/meta/agentErrorMeta.ts": { "statements": { "covered": 25, @@ -1182,31 +1314,31 @@ }, "src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts": { "statements": { - "covered": 125, - "total": 133, - "percentage": 93.98 + "covered": 183, + "total": 195, + "percentage": 93.84 }, "branches": { - "covered": 8, - "total": 14, - "percentage": 57.14 + "covered": 16, + "total": 29, + "percentage": 55.17 }, "functions": { - "covered": 3, - "total": 5, - "percentage": 60 + "covered": 7, + "total": 10, + "percentage": 70 }, "lines": { - "covered": 125, - "total": 133, - "percentage": 93.98 + "covered": 183, + "total": 195, + "percentage": 93.84 } }, "src/vs/platform/agentHost/common/meta/agentFeedbackAttachments.ts": { "statements": { "covered": 50, - "total": 126, - "percentage": 39.68 + "total": 129, + "percentage": 38.75 }, "branches": { "covered": 1, @@ -1220,8 +1352,8 @@ }, "lines": { "covered": 50, - "total": 126, - "percentage": 39.68 + "total": 129, + "percentage": 38.75 } }, "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { @@ -1290,16 +1422,38 @@ "percentage": 84.81 } }, - "src/vs/platform/agentHost/common/openSessionLink.ts": { + "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { "statements": { - "covered": 117, - "total": 151, - "percentage": 77.48 - }, - "branches": { - "covered": 15, - "total": 25, - "percentage": 60 + "covered": 28, + "total": 48, + "percentage": 58.33 + }, + "branches": { + "covered": 2, + "total": 8, + "percentage": 25 + }, + "functions": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "lines": { + "covered": 28, + "total": 48, + "percentage": 58.33 + } + }, + "src/vs/platform/agentHost/common/openSessionLink.ts": { + "statements": { + "covered": 120, + "total": 158, + "percentage": 75.94 + }, + "branches": { + "covered": 17, + "total": 28, + "percentage": 60.71 }, "functions": { "covered": 4, @@ -1307,9 +1461,9 @@ "percentage": 40 }, "lines": { - "covered": 117, - "total": 151, - "percentage": 77.48 + "covered": 120, + "total": 158, + "percentage": 75.94 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1446,9 +1600,9 @@ }, "src/vs/platform/agentHost/common/sandboxConfigSchema.ts": { "statements": { - "covered": 143, - "total": 143, - "percentage": 100 + "covered": 153, + "total": 156, + "percentage": 98.07 }, "branches": { "covered": 2, @@ -1457,13 +1611,13 @@ }, "functions": { "covered": 0, - "total": 0, - "percentage": 100 + "total": 1, + "percentage": 0 }, "lines": { - "covered": 143, - "total": 143, - "percentage": 100 + "covered": 153, + "total": 156, + "percentage": 98.07 } }, "src/vs/platform/agentHost/common/serverToolNames.ts": { @@ -1490,8 +1644,8 @@ }, "src/vs/platform/agentHost/common/sessionConfigKeys.ts": { "statements": { - "covered": 54, - "total": 54, + "covered": 58, + "total": 58, "percentage": 100 }, "branches": { @@ -1505,8 +1659,8 @@ "percentage": 100 }, "lines": { - "covered": 54, - "total": 54, + "covered": 58, + "total": 58, "percentage": 100 } }, @@ -1556,9 +1710,9 @@ }, "src/vs/platform/agentHost/common/state/agentSubscription.ts": { "statements": { - "covered": 582, - "total": 1201, - "percentage": 48.45 + "covered": 588, + "total": 1219, + "percentage": 48.23 }, "branches": { "covered": 2, @@ -1567,13 +1721,13 @@ }, "functions": { "covered": 1, - "total": 81, - "percentage": 1.23 + "total": 84, + "percentage": 1.19 }, "lines": { - "covered": 582, - "total": 1201, - "percentage": 48.45 + "covered": 588, + "total": 1219, + "percentage": 48.23 } }, "src/vs/platform/agentHost/common/state/chatAttachmentContext.ts": { @@ -1754,14 +1908,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts": { "statements": { - "covered": 299, + "covered": 311, "total": 420, - "percentage": 71.19 + "percentage": 74.04 }, "branches": { - "covered": 61, - "total": 88, - "percentage": 69.31 + "covered": 70, + "total": 96, + "percentage": 72.91 }, "functions": { "covered": 6, @@ -1769,9 +1923,9 @@ "percentage": 100 }, "lines": { - "covered": 299, + "covered": 311, "total": 420, - "percentage": 71.19 + "percentage": 74.04 } }, "src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts": { @@ -2045,9 +2199,9 @@ "percentage": 91.3 }, "branches": { - "covered": 10, - "total": 14, - "percentage": 71.42 + "covered": 9, + "total": 13, + "percentage": 69.23 }, "functions": { "covered": 3, @@ -2172,24 +2326,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1403, - "total": 1848, - "percentage": 75.91 + "covered": 1588, + "total": 1981, + "percentage": 80.16 }, "branches": { - "covered": 162, - "total": 233, - "percentage": 69.52 + "covered": 223, + "total": 325, + "percentage": 68.61 }, "functions": { - "covered": 58, - "total": 85, - "percentage": 68.23 + "covered": 69, + "total": 92, + "percentage": 75 }, "lines": { - "covered": 1403, - "total": 1848, - "percentage": 75.91 + "covered": 1588, + "total": 1981, + "percentage": 80.16 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -2326,9 +2480,9 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 385, - "total": 420, - "percentage": 91.66 + "covered": 389, + "total": 424, + "percentage": 91.74 }, "branches": { "covered": 45, @@ -2341,21 +2495,21 @@ "percentage": 94.44 }, "lines": { - "covered": 385, - "total": 420, - "percentage": 91.66 + "covered": 389, + "total": 424, + "percentage": 91.74 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { "statements": { - "covered": 102, - "total": 153, - "percentage": 66.66 + "covered": 129, + "total": 180, + "percentage": 71.66 }, "branches": { - "covered": 18, - "total": 30, - "percentage": 60 + "covered": 20, + "total": 32, + "percentage": 62.5 }, "functions": { "covered": 6, @@ -2363,9 +2517,9 @@ "percentage": 85.71 }, "lines": { - "covered": 102, - "total": 153, - "percentage": 66.66 + "covered": 129, + "total": 180, + "percentage": 71.66 } }, "src/vs/platform/agentHost/node/agentHostBangCommand.ts": { @@ -2392,8 +2546,8 @@ }, "src/vs/platform/agentHost/node/agentHostBootstrap.ts": { "statements": { - "covered": 62, - "total": 62, + "covered": 40, + "total": 40, "percentage": 100 }, "branches": { @@ -2407,8 +2561,8 @@ "percentage": 100 }, "lines": { - "covered": 62, - "total": 62, + "covered": 40, + "total": 40, "percentage": 100 } }, @@ -2436,14 +2590,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 380, + "covered": 378, "total": 440, - "percentage": 86.36 + "percentage": 85.9 }, "branches": { - "covered": 63, - "total": 85, - "percentage": 74.11 + "covered": 58, + "total": 81, + "percentage": 71.6 }, "functions": { "covered": 29, @@ -2451,21 +2605,21 @@ "percentage": 96.66 }, "lines": { - "covered": 380, + "covered": 378, "total": 440, - "percentage": 86.36 + "percentage": 85.9 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 264, + "covered": 265, "total": 296, - "percentage": 89.18 + "percentage": 89.52 }, "branches": { - "covered": 51, + "covered": 52, "total": 68, - "percentage": 75 + "percentage": 76.47 }, "functions": { "covered": 13, @@ -2473,31 +2627,31 @@ "percentage": 100 }, "lines": { - "covered": 264, + "covered": 265, "total": 296, - "percentage": 89.18 + "percentage": 89.52 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1227, + "covered": 1257, "total": 1646, - "percentage": 74.54 + "percentage": 76.36 }, "branches": { - "covered": 180, - "total": 254, - "percentage": 70.86 + "covered": 198, + "total": 273, + "percentage": 72.52 }, "functions": { - "covered": 56, + "covered": 57, "total": 70, - "percentage": 80 + "percentage": 81.42 }, "lines": { - "covered": 1227, + "covered": 1257, "total": 1646, - "percentage": 74.54 + "percentage": 76.36 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2507,9 +2661,9 @@ "percentage": 88.88 }, "branches": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 15, + "total": 18, + "percentage": 83.33 }, "functions": { "covered": 10, @@ -2573,9 +2727,9 @@ "percentage": 94.44 }, "branches": { - "covered": 43, - "total": 52, - "percentage": 82.69 + "covered": 44, + "total": 53, + "percentage": 83.01 }, "functions": { "covered": 6, @@ -2700,46 +2854,68 @@ }, "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { "statements": { - "covered": 554, + "covered": 569, "total": 723, - "percentage": 76.62 + "percentage": 78.69 }, "branches": { - "covered": 92, - "total": 137, - "percentage": 67.15 + "covered": 109, + "total": 150, + "percentage": 72.66 }, "functions": { - "covered": 41, + "covered": 42, "total": 47, - "percentage": 87.23 + "percentage": 89.36 }, "lines": { - "covered": 554, + "covered": 569, "total": 723, - "percentage": 76.62 + "percentage": 78.69 } }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 301, - "total": 360, - "percentage": 83.61 + "covered": 327, + "total": 426, + "percentage": 76.76 }, "branches": { - "covered": 45, - "total": 60, + "covered": 47, + "total": 63, + "percentage": 74.6 + }, + "functions": { + "covered": 24, + "total": 32, + "percentage": 75 + }, + "lines": { + "covered": 327, + "total": 426, + "percentage": 76.76 + } + }, + "src/vs/platform/agentHost/node/agentHostDebugLogs.ts": { + "statements": { + "covered": 73, + "total": 226, + "percentage": 32.3 + }, + "branches": { + "covered": 3, + "total": 4, "percentage": 75 }, "functions": { - "covered": 22, - "total": 28, - "percentage": 78.57 + "covered": 2, + "total": 9, + "percentage": 22.22 }, "lines": { - "covered": 301, - "total": 360, - "percentage": 83.61 + "covered": 73, + "total": 226, + "percentage": 32.3 } }, "src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts": { @@ -2793,9 +2969,9 @@ "percentage": 87.38 }, "branches": { - "covered": 60, - "total": 76, - "percentage": 78.94 + "covered": 61, + "total": 77, + "percentage": 79.22 }, "functions": { "covered": 9, @@ -2903,9 +3079,9 @@ "percentage": 70.35 }, "branches": { - "covered": 267, - "total": 375, - "percentage": 71.2 + "covered": 261, + "total": 369, + "percentage": 70.73 }, "functions": { "covered": 61, @@ -2920,14 +3096,14 @@ }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 }, "branches": { - "covered": 66, - "total": 90, - "percentage": 73.33 + "covered": 62, + "total": 87, + "percentage": 71.26 }, "functions": { "covered": 9, @@ -2935,9 +3111,9 @@ "percentage": 64.28 }, "lines": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -3140,24 +3316,24 @@ }, "src/vs/platform/agentHost/node/agentHostProxyResolver.ts": { "statements": { - "covered": 131, - "total": 179, - "percentage": 73.18 + "covered": 165, + "total": 230, + "percentage": 71.73 }, "branches": { - "covered": 15, + "covered": 17, "total": 20, - "percentage": 75 + "percentage": 85 }, "functions": { "covered": 13, - "total": 28, - "percentage": 46.42 + "total": 29, + "percentage": 44.82 }, "lines": { - "covered": 131, - "total": 179, - "percentage": 73.18 + "covered": 165, + "total": 230, + "percentage": 71.73 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { @@ -3250,9 +3426,9 @@ }, "src/vs/platform/agentHost/node/agentHostRequestService.ts": { "statements": { - "covered": 116, - "total": 197, - "percentage": 58.88 + "covered": 123, + "total": 219, + "percentage": 56.16 }, "branches": { "covered": 7, @@ -3261,20 +3437,20 @@ }, "functions": { "covered": 6, - "total": 11, - "percentage": 54.54 + "total": 14, + "percentage": 42.85 }, "lines": { - "covered": 116, - "total": 197, - "percentage": 58.88 + "covered": 123, + "total": 219, + "percentage": 56.16 } }, "src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts": { "statements": { - "covered": 174, - "total": 314, - "percentage": 55.41 + "covered": 176, + "total": 316, + "percentage": 55.69 }, "branches": { "covered": 2, @@ -3287,21 +3463,21 @@ "percentage": 6.66 }, "lines": { - "covered": 174, - "total": 314, - "percentage": 55.41 + "covered": 176, + "total": 316, + "percentage": 55.69 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { "statements": { - "covered": 209, + "covered": 207, "total": 264, - "percentage": 79.16 + "percentage": 78.4 }, "branches": { - "covered": 35, - "total": 52, - "percentage": 67.3 + "covered": 32, + "total": 50, + "percentage": 64 }, "functions": { "covered": 9, @@ -3309,16 +3485,16 @@ "percentage": 69.23 }, "lines": { - "covered": 209, + "covered": 207, "total": 264, - "percentage": 79.16 + "percentage": 78.4 } }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 458, - "total": 508, - "percentage": 90.15 + "covered": 464, + "total": 514, + "percentage": 90.27 }, "branches": { "covered": 23, @@ -3331,9 +3507,9 @@ "percentage": 85.71 }, "lines": { - "covered": 458, - "total": 508, - "percentage": 90.15 + "covered": 464, + "total": 514, + "percentage": 90.27 } }, "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { @@ -3470,24 +3646,24 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1636, - "total": 1776, - "percentage": 92.11 + "covered": 1726, + "total": 1900, + "percentage": 90.84 }, "branches": { - "covered": 257, - "total": 305, - "percentage": 84.26 + "covered": 280, + "total": 336, + "percentage": 83.33 }, "functions": { - "covered": 70, - "total": 78, - "percentage": 89.74 + "covered": 77, + "total": 87, + "percentage": 88.5 }, "lines": { - "covered": 1636, - "total": 1776, - "percentage": 92.11 + "covered": 1726, + "total": 1900, + "percentage": 90.84 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { @@ -3558,9 +3734,9 @@ }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1126, - "total": 1280, - "percentage": 87.96 + "covered": 1137, + "total": 1292, + "percentage": 88 }, "branches": { "covered": 66, @@ -3573,31 +3749,31 @@ "percentage": 69.56 }, "lines": { - "covered": 1126, - "total": 1280, - "percentage": 87.96 + "covered": 1137, + "total": 1292, + "percentage": 88 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { - "covered": 188, - "total": 278, - "percentage": 67.62 + "covered": 196, + "total": 291, + "percentage": 67.35 }, "branches": { "covered": 18, - "total": 44, - "percentage": 40.9 + "total": 46, + "percentage": 39.13 }, "functions": { "covered": 15, - "total": 29, - "percentage": 51.72 + "total": 30, + "percentage": 50 }, "lines": { - "covered": 188, - "total": 278, - "percentage": 67.62 + "covered": 196, + "total": 291, + "percentage": 67.35 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -3646,24 +3822,24 @@ }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { "statements": { - "covered": 392, - "total": 495, - "percentage": 79.19 + "covered": 423, + "total": 529, + "percentage": 79.96 }, "branches": { - "covered": 34, - "total": 46, - "percentage": 73.91 + "covered": 36, + "total": 50, + "percentage": 72 }, "functions": { - "covered": 19, - "total": 23, - "percentage": 82.6 + "covered": 22, + "total": 26, + "percentage": 84.61 }, "lines": { - "covered": 392, - "total": 495, - "percentage": 79.19 + "covered": 423, + "total": 529, + "percentage": 79.96 } }, "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { @@ -3710,6 +3886,50 @@ "percentage": 71.12 } }, + "src/vs/platform/agentHost/node/agentMergeController.ts": { + "statements": { + "covered": 267, + "total": 881, + "percentage": 30.3 + }, + "branches": { + "covered": 23, + "total": 49, + "percentage": 46.93 + }, + "functions": { + "covered": 13, + "total": 42, + "percentage": 30.95 + }, + "lines": { + "covered": 267, + "total": 881, + "percentage": 30.3 + } + }, + "src/vs/platform/agentHost/node/agentMergeTools.ts": { + "statements": { + "covered": 46, + "total": 141, + "percentage": 32.62 + }, + "branches": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 8, + "percentage": 25 + }, + "lines": { + "covered": 46, + "total": 141, + "percentage": 32.62 + } + }, "src/vs/platform/agentHost/node/agentModelRefreshScheduler.ts": { "statements": { "covered": 137, @@ -3800,68 +4020,68 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 4465, - "total": 5780, - "percentage": 77.24 + "covered": 5036, + "total": 6657, + "percentage": 75.64 }, "branches": { - "covered": 790, - "total": 1176, - "percentage": 67.17 + "covered": 914, + "total": 1352, + "percentage": 67.6 }, "functions": { - "covered": 193, - "total": 234, - "percentage": 82.47 + "covered": 220, + "total": 283, + "percentage": 77.73 }, "lines": { - "covered": 4465, - "total": 5780, - "percentage": 77.24 + "covered": 5036, + "total": 6657, + "percentage": 75.64 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 139, - "total": 156, - "percentage": 89.1 + "covered": 163, + "total": 207, + "percentage": 78.74 }, "branches": { - "covered": 12, - "total": 16, - "percentage": 75 + "covered": 15, + "total": 19, + "percentage": 78.94 }, "functions": { - "covered": 8, - "total": 11, - "percentage": 72.72 + "covered": 10, + "total": 15, + "percentage": 66.66 }, "lines": { - "covered": 139, - "total": 156, - "percentage": 89.1 + "covered": 163, + "total": 207, + "percentage": 78.74 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 2011, - "total": 2325, - "percentage": 86.49 + "covered": 2042, + "total": 2359, + "percentage": 86.56 }, "branches": { - "covered": 399, - "total": 513, - "percentage": 77.77 + "covered": 416, + "total": 534, + "percentage": 77.9 }, "functions": { - "covered": 66, - "total": 72, - "percentage": 91.66 + "covered": 67, + "total": 73, + "percentage": 91.78 }, "lines": { - "covered": 2011, - "total": 2325, - "percentage": 86.49 + "covered": 2042, + "total": 2359, + "percentage": 86.56 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -3888,24 +4108,24 @@ }, "src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts": { "statements": { - "covered": 119, + "covered": 117, "total": 204, - "percentage": 58.33 + "percentage": 57.35 }, "branches": { - "covered": 2, - "total": 2, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 2, + "covered": 1, "total": 13, - "percentage": 15.38 + "percentage": 7.69 }, "lines": { - "covered": 119, + "covered": 117, "total": 204, - "percentage": 58.33 + "percentage": 57.35 } }, "src/vs/platform/agentHost/node/claude/anthropicBetas.ts": { @@ -3954,24 +4174,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2126, - "total": 2624, - "percentage": 81.02 + "covered": 2144, + "total": 2651, + "percentage": 80.87 }, "branches": { - "covered": 212, - "total": 320, - "percentage": 66.25 + "covered": 213, + "total": 321, + "percentage": 66.35 }, "functions": { - "covered": 95, - "total": 119, - "percentage": 79.83 + "covered": 96, + "total": 122, + "percentage": 78.68 }, "lines": { - "covered": 2126, - "total": 2624, - "percentage": 81.02 + "covered": 2144, + "total": 2651, + "percentage": 80.87 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { @@ -3998,24 +4218,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1168, - "total": 1463, - "percentage": 79.83 + "covered": 1269, + "total": 1630, + "percentage": 77.85 }, "branches": { - "covered": 69, - "total": 111, - "percentage": 62.16 + "covered": 81, + "total": 136, + "percentage": 59.55 }, "functions": { - "covered": 37, - "total": 62, - "percentage": 59.67 + "covered": 42, + "total": 69, + "percentage": 60.86 }, "lines": { - "covered": 1168, - "total": 1463, - "percentage": 79.83 + "covered": 1269, + "total": 1630, + "percentage": 77.85 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -4106,6 +4326,28 @@ "percentage": 94 } }, + "src/vs/platform/agentHost/node/claude/claudeFolderPickerCriteria.ts": { + "statements": { + "covered": 29, + "total": 48, + "percentage": 60.41 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 29, + "total": 48, + "percentage": 60.41 + } + }, "src/vs/platform/agentHost/node/claude/claudeInteractiveTools.ts": { "statements": { "covered": 139, @@ -4130,14 +4372,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts": { "statements": { - "covered": 679, - "total": 759, - "percentage": 89.45 + "covered": 686, + "total": 766, + "percentage": 89.55 }, "branches": { "covered": 96, - "total": 131, - "percentage": 73.28 + "total": 132, + "percentage": 72.72 }, "functions": { "covered": 23, @@ -4145,9 +4387,9 @@ "percentage": 100 }, "lines": { - "covered": 679, - "total": 759, - "percentage": 89.45 + "covered": 686, + "total": 766, + "percentage": 89.55 } }, "src/vs/platform/agentHost/node/claude/claudeMcpServerNames.ts": { @@ -4350,46 +4592,46 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { "statements": { - "covered": 287, - "total": 347, - "percentage": 82.7 + "covered": 332, + "total": 403, + "percentage": 82.38 }, "branches": { - "covered": 14, - "total": 41, - "percentage": 34.14 + "covered": 15, + "total": 51, + "percentage": 29.41 }, "functions": { - "covered": 5, - "total": 8, - "percentage": 62.5 + "covered": 6, + "total": 9, + "percentage": 66.66 }, "lines": { - "covered": 287, - "total": 347, - "percentage": 82.7 + "covered": 332, + "total": 403, + "percentage": 82.38 } }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { "statements": { - "covered": 557, + "covered": 573, "total": 739, - "percentage": 75.37 + "percentage": 77.53 }, "branches": { - "covered": 39, - "total": 62, - "percentage": 62.9 + "covered": 41, + "total": 65, + "percentage": 63.07 }, "functions": { - "covered": 20, + "covered": 21, "total": 31, - "percentage": 64.51 + "percentage": 67.74 }, "lines": { - "covered": 557, + "covered": 573, "total": 739, - "percentage": 75.37 + "percentage": 77.53 } }, "src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts": { @@ -4504,14 +4746,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": { "statements": { - "covered": 287, - "total": 316, - "percentage": 90.82 + "covered": 291, + "total": 319, + "percentage": 91.22 }, "branches": { - "covered": 18, - "total": 44, - "percentage": 40.9 + "covered": 21, + "total": 48, + "percentage": 43.75 }, "functions": { "covered": 5, @@ -4519,9 +4761,9 @@ "percentage": 100 }, "lines": { - "covered": 287, - "total": 316, - "percentage": 90.82 + "covered": 291, + "total": 319, + "percentage": 91.22 } }, "src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts": { @@ -4724,9 +4966,9 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts": { "statements": { - "covered": 19, - "total": 42, - "percentage": 45.23 + "covered": 24, + "total": 52, + "percentage": 46.15 }, "branches": { "covered": 1, @@ -4739,16 +4981,16 @@ "percentage": 33.33 }, "lines": { - "covered": 19, - "total": 42, - "percentage": 45.23 + "covered": 24, + "total": 52, + "percentage": 46.15 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts": { "statements": { "covered": 49, - "total": 72, - "percentage": 68.05 + "total": 79, + "percentage": 62.02 }, "branches": { "covered": 2, @@ -4757,13 +4999,13 @@ }, "functions": { "covered": 2, - "total": 4, - "percentage": 50 + "total": 5, + "percentage": 40 }, "lines": { "covered": 49, - "total": 72, - "percentage": 68.05 + "total": 79, + "percentage": 62.02 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { @@ -4790,24 +5032,24 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { "statements": { - "covered": 424, - "total": 550, - "percentage": 77.09 + "covered": 434, + "total": 558, + "percentage": 77.77 }, "branches": { - "covered": 43, - "total": 68, - "percentage": 63.23 + "covered": 46, + "total": 70, + "percentage": 65.71 }, "functions": { - "covered": 12, - "total": 14, - "percentage": 85.71 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "lines": { - "covered": 424, - "total": 550, - "percentage": 77.09 + "covered": 434, + "total": 558, + "percentage": 77.77 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts": { @@ -4856,24 +5098,24 @@ }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts": { "statements": { - "covered": 69, + "covered": 76, "total": 91, - "percentage": 75.82 + "percentage": 83.51 }, "branches": { - "covered": 4, - "total": 9, - "percentage": 44.44 + "covered": 5, + "total": 12, + "percentage": 41.66 }, "functions": { - "covered": 3, + "covered": 4, "total": 4, - "percentage": 75 + "percentage": 100 }, "lines": { - "covered": 69, + "covered": 76, "total": 91, - "percentage": 75.82 + "percentage": 83.51 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts": { @@ -4944,24 +5186,24 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 4112, - "total": 6217, - "percentage": 66.14 + "covered": 4426, + "total": 6729, + "percentage": 65.77 }, "branches": { - "covered": 410, - "total": 767, - "percentage": 53.45 + "covered": 514, + "total": 908, + "percentage": 56.6 }, "functions": { - "covered": 168, - "total": 227, - "percentage": 74 + "covered": 186, + "total": 252, + "percentage": 73.8 }, "lines": { - "covered": 4112, - "total": 6217, - "percentage": 66.14 + "covered": 4426, + "total": 6729, + "percentage": 65.77 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { @@ -4988,24 +5230,24 @@ }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 300, - "total": 360, - "percentage": 83.33 + "covered": 308, + "total": 372, + "percentage": 82.79 }, "branches": { - "covered": 32, - "total": 62, - "percentage": 51.61 + "covered": 33, + "total": 64, + "percentage": 51.56 }, "functions": { - "covered": 19, - "total": 23, - "percentage": 82.6 + "covered": 20, + "total": 24, + "percentage": 83.33 }, "lines": { - "covered": 300, - "total": 360, - "percentage": 83.33 + "covered": 308, + "total": 372, + "percentage": 82.79 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { @@ -5074,6 +5316,28 @@ "percentage": 27.68 } }, + "src/vs/platform/agentHost/node/codex/codexFolderPickerCriteria.ts": { + "statements": { + "covered": 21, + "total": 21, + "percentage": 100 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 21, + "total": 21, + "percentage": 100 + } + }, "src/vs/platform/agentHost/node/codex/codexForkPlan.ts": { "statements": { "covered": 54, @@ -5164,46 +5428,46 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 629, - "total": 1258, - "percentage": 50 + "covered": 644, + "total": 1281, + "percentage": 50.27 }, "branches": { - "covered": 62, - "total": 119, - "percentage": 52.1 + "covered": 60, + "total": 114, + "percentage": 52.63 }, "functions": { "covered": 18, - "total": 42, - "percentage": 42.85 + "total": 43, + "percentage": 41.86 }, "lines": { - "covered": 629, - "total": 1258, - "percentage": 50 + "covered": 644, + "total": 1281, + "percentage": 50.27 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { "statements": { - "covered": 258, - "total": 357, - "percentage": 72.26 + "covered": 363, + "total": 435, + "percentage": 83.44 }, "branches": { - "covered": 13, - "total": 24, - "percentage": 54.16 + "covered": 44, + "total": 61, + "percentage": 72.13 }, "functions": { - "covered": 9, - "total": 16, - "percentage": 56.25 + "covered": 20, + "total": 24, + "percentage": 83.33 }, "lines": { - "covered": 258, - "total": 357, - "percentage": 72.26 + "covered": 363, + "total": 435, + "percentage": 83.44 } }, "src/vs/platform/agentHost/node/codex/codexPromptResolver.ts": { @@ -5274,36 +5538,36 @@ }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 178, + "covered": 79, "total": 378, - "percentage": 47.08 + "percentage": 20.89 }, "branches": { - "covered": 6, - "total": 26, - "percentage": 23.07 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 4, - "total": 13, - "percentage": 30.76 + "covered": 0, + "total": 12, + "percentage": 0 }, "lines": { - "covered": 178, + "covered": 79, "total": 378, - "percentage": 47.08 + "percentage": 20.89 } }, "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, @@ -5311,9 +5575,9 @@ "percentage": 100 }, "lines": { - "covered": 117, + "covered": 125, "total": 159, - "percentage": 73.58 + "percentage": 78.61 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -5323,9 +5587,9 @@ "percentage": 87.87 }, "branches": { - "covered": 23, - "total": 45, - "percentage": 51.11 + "covered": 22, + "total": 44, + "percentage": 50 }, "functions": { "covered": 11, @@ -5340,14 +5604,14 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 229, + "covered": 230, "total": 256, - "percentage": 89.45 + "percentage": 89.84 }, "branches": { - "covered": 18, - "total": 39, - "percentage": 46.15 + "covered": 20, + "total": 37, + "percentage": 54.05 }, "functions": { "covered": 7, @@ -5355,9 +5619,9 @@ "percentage": 100 }, "lines": { - "covered": 229, + "covered": 230, "total": 256, - "percentage": 89.45 + "percentage": 89.84 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { @@ -5384,24 +5648,24 @@ }, "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { "statements": { - "covered": 67, + "covered": 42, "total": 189, - "percentage": 35.44 + "percentage": 22.22 }, "branches": { - "covered": 1, - "total": 9, - "percentage": 11.11 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 1, + "covered": 0, "total": 8, - "percentage": 12.5 + "percentage": 0 }, "lines": { - "covered": 67, + "covered": 42, "total": 189, - "percentage": 35.44 + "percentage": 22.22 } }, "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { @@ -5494,14 +5758,14 @@ }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { "statements": { - "covered": 97, - "total": 139, - "percentage": 69.78 + "covered": 104, + "total": 148, + "percentage": 70.27 }, "branches": { "covered": 4, - "total": 6, - "percentage": 66.66 + "total": 8, + "percentage": 50 }, "functions": { "covered": 4, @@ -5509,9 +5773,9 @@ "percentage": 30.76 }, "lines": { - "covered": 97, - "total": 139, - "percentage": 69.78 + "covered": 104, + "total": 148, + "percentage": 70.27 } }, "src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts": { @@ -5582,46 +5846,68 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4394, - "total": 5846, - "percentage": 75.16 + "covered": 4701, + "total": 6199, + "percentage": 75.83 }, "branches": { - "covered": 723, - "total": 1116, - "percentage": 64.78 + "covered": 787, + "total": 1200, + "percentage": 65.58 }, "functions": { - "covered": 247, - "total": 302, - "percentage": 81.78 + "covered": 266, + "total": 321, + "percentage": 82.86 }, "lines": { - "covered": 4394, - "total": 5846, - "percentage": 75.16 + "covered": 4701, + "total": 6199, + "percentage": 75.83 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4137, - "total": 5550, - "percentage": 74.54 + "covered": 4259, + "total": 5785, + "percentage": 73.62 }, "branches": { - "covered": 740, - "total": 1077, - "percentage": 68.7 + "covered": 775, + "total": 1120, + "percentage": 69.19 }, "functions": { - "covered": 175, - "total": 216, - "percentage": 81.01 + "covered": 180, + "total": 224, + "percentage": 80.35 }, "lines": { - "covered": 4137, - "total": 5550, - "percentage": 74.54 + "covered": 4259, + "total": 5785, + "percentage": 73.62 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts": { + "statements": { + "covered": 37, + "total": 44, + "percentage": 84.09 + }, + "branches": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "functions": { + "covered": 3, + "total": 5, + "percentage": 60 + }, + "lines": { + "covered": 37, + "total": 44, + "percentage": 84.09 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -5670,24 +5956,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts": { "statements": { - "covered": 295, - "total": 405, - "percentage": 72.83 + "covered": 325, + "total": 459, + "percentage": 70.8 }, "branches": { - "covered": 6, - "total": 23, - "percentage": 26.08 + "covered": 7, + "total": 24, + "percentage": 29.16 }, "functions": { - "covered": 5, - "total": 11, - "percentage": 45.45 + "covered": 6, + "total": 14, + "percentage": 42.85 }, "lines": { - "covered": 295, - "total": 405, - "percentage": 72.83 + "covered": 325, + "total": 459, + "percentage": 70.8 } }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { @@ -5758,14 +6044,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 369, - "total": 518, - "percentage": 71.23 + "covered": 375, + "total": 520, + "percentage": 72.11 }, "branches": { "covered": 42, - "total": 75, - "percentage": 56 + "total": 78, + "percentage": 53.84 }, "functions": { "covered": 19, @@ -5773,9 +6059,9 @@ "percentage": 76 }, "lines": { - "covered": 369, - "total": 518, - "percentage": 71.23 + "covered": 375, + "total": 520, + "percentage": 72.11 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -5803,23 +6089,23 @@ "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { "covered": 677, - "total": 860, - "percentage": 78.72 + "total": 878, + "percentage": 77.1 }, "branches": { - "covered": 80, - "total": 120, - "percentage": 66.66 + "covered": 83, + "total": 122, + "percentage": 68.03 }, "functions": { "covered": 34, - "total": 45, - "percentage": 75.55 + "total": 46, + "percentage": 73.91 }, "lines": { "covered": 677, - "total": 860, - "percentage": 78.72 + "total": 878, + "percentage": 77.1 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { @@ -5846,24 +6132,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotShellTools.ts": { "statements": { - "covered": 522, - "total": 811, - "percentage": 64.36 + "covered": 528, + "total": 817, + "percentage": 64.62 }, "branches": { - "covered": 30, - "total": 50, - "percentage": 60 + "covered": 32, + "total": 52, + "percentage": 61.53 }, "functions": { - "covered": 18, - "total": 31, - "percentage": 58.06 + "covered": 19, + "total": 32, + "percentage": 59.37 }, "lines": { - "covered": 522, - "total": 811, - "percentage": 64.36 + "covered": 528, + "total": 817, + "percentage": 64.62 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { @@ -6000,14 +6286,14 @@ }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 666, - "total": 948, - "percentage": 70.25 + "covered": 672, + "total": 956, + "percentage": 70.29 }, "branches": { "covered": 87, - "total": 170, - "percentage": 51.17 + "total": 168, + "percentage": 51.78 }, "functions": { "covered": 21, @@ -6015,9 +6301,9 @@ "percentage": 95.45 }, "lines": { - "covered": 666, - "total": 948, - "percentage": 70.25 + "covered": 672, + "total": 956, + "percentage": 70.29 } }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { @@ -6176,31 +6462,31 @@ }, "src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts": { "statements": { - "covered": 78, - "total": 117, - "percentage": 66.66 + "covered": 100, + "total": 142, + "percentage": 70.42 }, "branches": { - "covered": 1, - "total": 23, - "percentage": 4.34 + "covered": 2, + "total": 30, + "percentage": 6.66 }, "functions": { - "covered": 1, - "total": 1, - "percentage": 100 + "covered": 2, + "total": 3, + "percentage": 66.66 }, "lines": { - "covered": 78, - "total": 117, - "percentage": 66.66 + "covered": 100, + "total": 142, + "percentage": 70.42 } }, "src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts": { "statements": { - "covered": 1111, - "total": 1224, - "percentage": 90.76 + "covered": 1116, + "total": 1279, + "percentage": 87.25 }, "branches": { "covered": 220, @@ -6209,13 +6495,13 @@ }, "functions": { "covered": 36, - "total": 37, - "percentage": 97.29 + "total": 38, + "percentage": 94.73 }, "lines": { - "covered": 1111, - "total": 1224, - "percentage": 90.76 + "covered": 1116, + "total": 1279, + "percentage": 87.25 } }, "src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts": { @@ -6379,9 +6665,9 @@ "percentage": 100 }, "branches": { - "covered": 17, - "total": 18, - "percentage": 94.44 + "covered": 18, + "total": 19, + "percentage": 94.73 }, "functions": { "covered": 5, @@ -6396,14 +6682,14 @@ }, "src/vs/platform/agentHost/node/networkDiagnosticsService.ts": { "statements": { - "covered": 181, - "total": 195, - "percentage": 92.82 + "covered": 182, + "total": 196, + "percentage": 92.85 }, "branches": { - "covered": 22, - "total": 32, - "percentage": 68.75 + "covered": 18, + "total": 31, + "percentage": 58.06 }, "functions": { "covered": 8, @@ -6411,9 +6697,9 @@ "percentage": 100 }, "lines": { - "covered": 181, - "total": 195, - "percentage": 92.82 + "covered": 182, + "total": 196, + "percentage": 92.85 } }, "src/vs/platform/agentHost/node/osc633Parser.ts": { @@ -6462,24 +6748,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1627, - "total": 1830, - "percentage": 88.9 + "covered": 1643, + "total": 1925, + "percentage": 85.35 }, "branches": { - "covered": 328, - "total": 398, - "percentage": 82.41 + "covered": 330, + "total": 408, + "percentage": 80.88 }, "functions": { - "covered": 77, - "total": 88, - "percentage": 87.5 + "covered": 83, + "total": 89, + "percentage": 93.25 }, "lines": { - "covered": 1627, - "total": 1830, - "percentage": 88.9 + "covered": 1643, + "total": 1925, + "percentage": 85.35 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -6504,6 +6790,28 @@ "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, @@ -6528,24 +6836,24 @@ }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 738, + "covered": 749, "total": 882, - "percentage": 83.67 + "percentage": 84.92 }, "branches": { - "covered": 113, + "covered": 111, "total": 139, - "percentage": 81.29 + "percentage": 79.85 }, "functions": { - "covered": 39, + "covered": 40, "total": 54, - "percentage": 72.22 + "percentage": 74.07 }, "lines": { - "covered": 738, + "covered": 749, "total": 882, - "percentage": 83.67 + "percentage": 84.92 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -6638,24 +6946,24 @@ }, "src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts": { "statements": { - "covered": 564, - "total": 611, - "percentage": 92.3 + "covered": 612, + "total": 682, + "percentage": 89.73 }, "branches": { "covered": 60, - "total": 92, - "percentage": 65.21 + "total": 97, + "percentage": 61.85 }, "functions": { - "covered": 25, - "total": 26, - "percentage": 96.15 + "covered": 26, + "total": 27, + "percentage": 96.29 }, "lines": { - "covered": 564, - "total": 611, - "percentage": 92.3 + "covered": 612, + "total": 682, + "percentage": 89.73 } }, "src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts": { @@ -6680,26 +6988,48 @@ "percentage": 41.57 } }, - "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { + "src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts": { "statements": { - "covered": 171, - "total": 181, - "percentage": 94.47 + "covered": 72, + "total": 150, + "percentage": 48 }, "branches": { - "covered": 19, - "total": 26, - "percentage": 73.07 + "covered": 2, + "total": 3, + "percentage": 66.66 }, "functions": { - "covered": 8, + "covered": 2, "total": 8, + "percentage": 25 + }, + "lines": { + "covered": 72, + "total": 150, + "percentage": 48 + } + }, + "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { + "statements": { + "covered": 183, + "total": 193, + "percentage": 94.81 + }, + "branches": { + "covered": 20, + "total": 27, + "percentage": 74.07 + }, + "functions": { + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 171, - "total": 181, - "percentage": 94.47 + "covered": 183, + "total": 193, + "percentage": 94.81 } }, "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { @@ -6731,9 +7061,9 @@ "percentage": 86.46 }, "branches": { - "covered": 64, - "total": 109, - "percentage": 58.71 + "covered": 58, + "total": 103, + "percentage": 56.31 }, "functions": { "covered": 27, @@ -6748,14 +7078,14 @@ }, "src/vs/platform/agentHost/node/shared/customizationEnablementGate.ts": { "statements": { - "covered": 154, + "covered": 164, "total": 185, - "percentage": 83.24 + "percentage": 88.64 }, "branches": { - "covered": 48, - "total": 56, - "percentage": 85.71 + "covered": 54, + "total": 60, + "percentage": 90 }, "functions": { "covered": 8, @@ -6763,9 +7093,9 @@ "percentage": 88.88 }, "lines": { - "covered": 154, + "covered": 164, "total": 185, - "percentage": 83.24 + "percentage": 88.64 } }, "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { @@ -6858,14 +7188,14 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 239, + "covered": 237, "total": 253, - "percentage": 94.46 + "percentage": 93.67 }, "branches": { "covered": 36, - "total": 42, - "percentage": 85.71 + "total": 43, + "percentage": 83.72 }, "functions": { "covered": 7, @@ -6873,9 +7203,53 @@ "percentage": 100 }, "lines": { - "covered": 239, + "covered": 237, "total": 253, - "percentage": 94.46 + "percentage": 93.67 + } + }, + "src/vs/platform/agentHost/node/shared/folderPickerDecision.ts": { + "statements": { + "covered": 41, + "total": 47, + "percentage": 87.23 + }, + "branches": { + "covered": 3, + "total": 6, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 41, + "total": 47, + "percentage": 87.23 + } + }, + "src/vs/platform/agentHost/node/shared/githubMcpServer.ts": { + "statements": { + "covered": 87, + "total": 98, + "percentage": 88.77 + }, + "branches": { + "covered": 5, + "total": 9, + "percentage": 55.55 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 87, + "total": 98, + "percentage": 88.77 } }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { @@ -6902,46 +7276,68 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 464, - "total": 556, - "percentage": 83.45 + "covered": 491, + "total": 586, + "percentage": 83.78 }, "branches": { - "covered": 85, - "total": 103, - "percentage": 82.52 + "covered": 107, + "total": 122, + "percentage": 87.7 }, "functions": { - "covered": 25, - "total": 31, - "percentage": 80.64 + "covered": 26, + "total": 32, + "percentage": 81.25 }, "lines": { - "covered": 464, - "total": 556, - "percentage": 83.45 + "covered": 491, + "total": 586, + "percentage": 83.78 + } + }, + "src/vs/platform/agentHost/node/shared/mcpServerWorkingDirectory.ts": { + "statements": { + "covered": 18, + "total": 20, + "percentage": 90 + }, + "branches": { + "covered": 7, + "total": 10, + "percentage": 70 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 18, + "total": 20, + "percentage": 90 } }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { "statements": { - "covered": 48, + "covered": 56, "total": 60, - "percentage": 80 + "percentage": 93.33 }, "branches": { - "covered": 4, - "total": 5, - "percentage": 80 + "covered": 5, + "total": 6, + "percentage": 83.33 }, "functions": { - "covered": 3, + "covered": 4, "total": 5, - "percentage": 60 + "percentage": 80 }, "lines": { - "covered": 48, + "covered": 56, "total": 60, - "percentage": 80 + "percentage": 93.33 } }, "src/vs/platform/agentHost/node/shared/proxyChatError.ts": { @@ -6968,9 +7364,9 @@ }, "src/vs/platform/agentHost/node/shared/serverToolGroups.ts": { "statements": { - "covered": 70, - "total": 72, - "percentage": 97.22 + "covered": 75, + "total": 77, + "percentage": 97.4 }, "branches": { "covered": 9, @@ -6983,31 +7379,53 @@ "percentage": 100 }, "lines": { - "covered": 70, - "total": 72, - "percentage": 97.22 + "covered": 75, + "total": 77, + "percentage": 97.4 + } + }, + "src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts": { + "statements": { + "covered": 162, + "total": 200, + "percentage": 81 + }, + "branches": { + "covered": 27, + "total": 33, + "percentage": 81.81 + }, + "functions": { + "covered": 10, + "total": 10, + "percentage": 100 + }, + "lines": { + "covered": 162, + "total": 200, + "percentage": 81 } }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1038, - "total": 1258, - "percentage": 82.51 + "covered": 1111, + "total": 1399, + "percentage": 79.41 }, "branches": { - "covered": 155, - "total": 243, - "percentage": 63.78 + "covered": 161, + "total": 277, + "percentage": 58.12 }, "functions": { - "covered": 48, - "total": 58, - "percentage": 82.75 + "covered": 49, + "total": 60, + "percentage": 81.66 }, "lines": { - "covered": 1038, - "total": 1258, - "percentage": 82.51 + "covered": 1111, + "total": 1399, + "percentage": 79.41 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { 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 be154ed9eb421f..42ad596ca9277a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -11,6 +11,7 @@ import { defineCustomizationDiscoveryTests } from './customizationDiscoverySuite import { defineAnnotationsTests } from './annotationsSuite.js'; import { defineChangesetTests } from './changesetSuite.js'; import { defineClientFilesystemTests } from './clientFilesystemSuite.js'; +import { defineClientHostedFilesystemTests } from './clientHostedFilesystemSuite.js'; import { defineProtocolContractTests } from './protocolContractsSuite.js'; import { defineServerToolsTests } from './serverToolsSuite.js'; import { defineSessionPersistenceTests } from './sessionPersistenceSuite.js'; @@ -146,6 +147,7 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); + defineClientHostedFilesystemTests(context); defineAnnotationsTests(context); defineProtocolContractTests(context); } diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/clientHostedFilesystemSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/clientHostedFilesystemSuite.ts new file mode 100644 index 00000000000000..ee6cc6abbb7f92 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/clientHostedFilesystemSuite.ts @@ -0,0 +1,528 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The reverse half of the symmetric AHP filesystem contract. + * + * These scenarios address local files through `vscode-agent-client:` URIs. The + * host must decode and route each operation back over the WebSocket to the + * client that owns the URI, then return that client's result to the caller. + */ + +import assert from 'assert'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { toAgentClientUri } from '../../../../common/agentClientUri.js'; +import type { ResourceListResult, ResourceReadResult, ResourceResolveResult } from '../../../../common/state/protocol/commands.js'; +import { ContentEncoding, ResourceType, ResourceWriteMode } from '../../../../common/state/protocol/common/commands.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { AhpErrorCodes } from '../../../../common/state/sessionProtocol.js'; +import { ROOT_STATE_URI } from '../../../../common/state/sessionState.js'; +import type { IServedReverseRequest } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +export function defineClientHostedFilesystemTests(context: IAgentHostE2ETestContext): void { + const { config, tempDirs } = context; + + function createWorkspace(prefix: string): string { + const workspace = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(workspace); + return workspace; + } + + async function initializeClient(prefix: string): Promise { + const clientId = `client-hosted-fs-${prefix}-${config.provider}`; + await context.client.call('initialize', { + channel: ROOT_STATE_URI, + protocolVersions: [PROTOCOL_VERSION], + clientId, + }); + context.client.clearServedReverseRequests(); + return clientId; + } + + function clientUri(clientId: string, path: string): string { + return toAgentClientUri(URI.file(path), clientId).toString(); + } + + function assertReverseRequest(expected: IServedReverseRequest): void { + assert.ok(context.client.servedReverseRequests.some(request => + request.method === expected.method && request.uri === expected.uri + ), `served reverse requests: ${JSON.stringify(context.client.servedReverseRequests)}`); + } + + conformanceTest(context, 'client-hosted resourceRead returns UTF-8 text through reverse RPC', async function () { + const clientId = await initializeClient('read-text'); + const workspace = createWorkspace('ahp-client-hosted-read-text-'); + const file = join(workspace, 'note.txt'); + writeFileSync(file, 'CLIENT_HOSTED_TEXT'); + + const result = await context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + encoding: ContentEncoding.Utf8, + }); + + assert.deepStrictEqual(result, { data: 'CLIENT_HOSTED_TEXT', encoding: ContentEncoding.Utf8, contentType: 'text/plain' }); + assertReverseRequest({ method: 'resourceRead', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceRead preserves arbitrary base64 bytes', async function () { + const clientId = await initializeClient('read-binary'); + const workspace = createWorkspace('ahp-client-hosted-read-binary-'); + const file = join(workspace, 'bytes.bin'); + const bytes = Buffer.from([0, 1, 2, 127, 128, 254, 255]); + writeFileSync(file, bytes); + + const result = await context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + encoding: ContentEncoding.Base64, + }); + + assert.deepStrictEqual({ + encoding: result.encoding, + bytes: Buffer.from(result.data, 'base64'), + }, { + encoding: ContentEncoding.Base64, + bytes, + }); + assertReverseRequest({ method: 'resourceRead', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceRead propagates a missing-file error', async function () { + const clientId = await initializeClient('read-missing'); + const workspace = createWorkspace('ahp-client-hosted-read-missing-'); + const file = join(workspace, 'missing.txt'); + + await assert.rejects(context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + encoding: ContentEncoding.Utf8, + }), { code: AhpErrorCodes.NotFound }); + assertReverseRequest({ method: 'resourceRead', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceList returns file and directory entries', async function () { + const clientId = await initializeClient('list'); + const workspace = createWorkspace('ahp-client-hosted-list-'); + mkdirSync(join(workspace, 'child-dir')); + writeFileSync(join(workspace, 'child.txt'), 'child'); + + const result = await context.client.call('resourceList', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, workspace), + }); + + assert.deepStrictEqual([...result.entries].sort((a, b) => a.name.localeCompare(b.name)), [ + { name: 'child-dir', type: 'directory' }, + { name: 'child.txt', type: 'file' }, + ]); + assertReverseRequest({ method: 'resourceList', uri: URI.file(workspace).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceList propagates a missing-directory error', async function () { + const clientId = await initializeClient('list-missing'); + const workspace = createWorkspace('ahp-client-hosted-list-missing-'); + const directory = join(workspace, 'missing'); + + await assert.rejects(context.client.call('resourceList', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, directory), + }), { code: AhpErrorCodes.NotFound }); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(directory).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceResolve returns file metadata', async function () { + const clientId = await initializeClient('resolve-file'); + const workspace = createWorkspace('ahp-client-hosted-resolve-file-'); + const file = join(workspace, 'metadata.txt'); + writeFileSync(file, 'metadata'); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + }); + + assert.deepStrictEqual({ + type: result.type, + size: result.size, + hasEtag: typeof result.etag === 'string', + hasModifiedTime: typeof result.mtime === 'string', + }, { + type: ResourceType.File, + size: 8, + hasEtag: true, + hasModifiedTime: true, + }); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceResolve returns directory metadata', async function () { + const clientId = await initializeClient('resolve-directory'); + const workspace = createWorkspace('ahp-client-hosted-resolve-directory-'); + const directory = join(workspace, 'nested'); + mkdirSync(directory); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, directory), + }); + + assert.deepStrictEqual({ type: result.type, size: result.size }, { type: ResourceType.Directory, size: 0 }); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(directory).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceResolve reflects a changed file etag', async function () { + const clientId = await initializeClient('resolve-etag'); + const workspace = createWorkspace('ahp-client-hosted-resolve-etag-'); + const file = join(workspace, 'etag.txt'); + writeFileSync(file, 'short'); + const uri = clientUri(clientId, file); + + const before = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri }); + writeFileSync(file, 'longer-content'); + const after = await context.client.call('resourceResolve', { channel: ROOT_STATE_URI, uri }); + + assert.notStrictEqual(after.etag, before.etag); + assert.deepStrictEqual(context.client.servedReverseRequests.map(request => request.method), ['resourceResolve', 'resourceResolve']); + }); + + conformanceTest(context, 'client-hosted resourceWrite truncates an existing file', async function () { + const clientId = await initializeClient('write-truncate'); + const workspace = createWorkspace('ahp-client-hosted-write-truncate-'); + const file = join(workspace, 'write.txt'); + writeFileSync(file, 'before'); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: 'after', + encoding: ContentEncoding.Utf8, + }); + + assert.strictEqual(readFileSync(file, 'utf8'), 'after'); + assertReverseRequest({ method: 'resourceWrite', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceWrite truncates from a byte position', async function () { + const clientId = await initializeClient('write-truncate-position'); + const workspace = createWorkspace('ahp-client-hosted-write-truncate-position-'); + const file = join(workspace, 'write.txt'); + writeFileSync(file, 'abcdef'); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: 'XY', + encoding: ContentEncoding.Utf8, + mode: ResourceWriteMode.Truncate, + position: 3, + }); + + assert.strictEqual(readFileSync(file, 'utf8'), 'abcXY'); + assertReverseRequest({ method: 'resourceWrite', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceWrite appends at EOF', async function () { + const clientId = await initializeClient('write-append'); + const workspace = createWorkspace('ahp-client-hosted-write-append-'); + const file = join(workspace, 'write.txt'); + writeFileSync(file, 'first'); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: '-second', + encoding: ContentEncoding.Utf8, + mode: ResourceWriteMode.Append, + }); + + assert.strictEqual(readFileSync(file, 'utf8'), 'first-second'); + assertReverseRequest({ method: 'resourceWrite', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceWrite inserts at a byte position', async function () { + const clientId = await initializeClient('write-insert'); + const workspace = createWorkspace('ahp-client-hosted-write-insert-'); + const file = join(workspace, 'write.txt'); + writeFileSync(file, 'ac'); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: 'b', + encoding: ContentEncoding.Utf8, + mode: ResourceWriteMode.Insert, + position: 1, + }); + + assert.strictEqual(readFileSync(file, 'utf8'), 'abc'); + assertReverseRequest({ method: 'resourceWrite', uri: URI.file(file).toString() }); + }); + + // Reverse binary writes currently pass through UTF-8; see KNOWN_ISSUES.md. + conformanceTest(context, 'client-hosted resourceWrite decodes base64 content', async function () { + const clientId = await initializeClient('write-binary'); + const workspace = createWorkspace('ahp-client-hosted-write-binary-'); + const file = join(workspace, 'write.bin'); + const bytes = Buffer.from([0, 255, 1, 254]); + + await context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: bytes.toString('base64'), + encoding: ContentEncoding.Base64, + }); + + assert.deepStrictEqual(readFileSync(file), bytes); + assertReverseRequest({ method: 'resourceWrite', uri: URI.file(file).toString() }); + }, context.runHostOnlyKnownIssueTests); + + conformanceTest(context, 'client-hosted resourceWrite createOnly preserves an existing file', async function () { + const clientId = await initializeClient('write-create-only'); + const workspace = createWorkspace('ahp-client-hosted-write-create-only-'); + const file = join(workspace, 'existing.txt'); + writeFileSync(file, 'existing'); + + await assert.rejects(context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + data: 'replacement', + encoding: ContentEncoding.Utf8, + createOnly: true, + }), { code: AhpErrorCodes.AlreadyExists }); + + assert.strictEqual(readFileSync(file, 'utf8'), 'existing'); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted concurrent resourceWrite createOnly calls have a single winner', async function () { + const clientId = await initializeClient('write-create-only-concurrent'); + const workspace = createWorkspace('ahp-client-hosted-write-create-only-concurrent-'); + const file = join(workspace, 'winner.txt'); + const uri = clientUri(clientId, file); + + const results = await Promise.allSettled([ + context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri, + data: 'first', + encoding: ContentEncoding.Utf8, + createOnly: true, + }), + context.client.call('resourceWrite', { + channel: ROOT_STATE_URI, + uri, + data: 'second', + encoding: ContentEncoding.Utf8, + createOnly: true, + }), + ]); + + assert.deepStrictEqual({ + statuses: results.map(result => result.status).sort(), + contentIsWinner: ['first', 'second'].includes(readFileSync(file, 'utf8')), + reverseWrites: context.client.servedReverseRequests.filter(request => request.method === 'resourceWrite').length, + }, { + statuses: ['fulfilled', 'rejected'], + contentIsWinner: true, + reverseWrites: 1, + }); + }); + + conformanceTest(context, 'client-hosted resourceMkdir creates missing parent directories', async function () { + const clientId = await initializeClient('mkdir'); + const workspace = createWorkspace('ahp-client-hosted-mkdir-'); + const directory = join(workspace, 'one', 'two'); + + await context.client.call('resourceMkdir', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, directory), + }); + + assert.strictEqual(existsSync(directory), true); + assertReverseRequest({ method: 'resourceMkdir', uri: URI.file(directory).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceCopy copies a file', async function () { + const clientId = await initializeClient('copy-file'); + const workspace = createWorkspace('ahp-client-hosted-copy-file-'); + const source = join(workspace, 'source.txt'); + const destination = join(workspace, 'nested', 'destination.txt'); + writeFileSync(source, 'copied'); + + await context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + }); + + assert.strictEqual(readFileSync(destination, 'utf8'), 'copied'); + assertReverseRequest({ method: 'resourceCopy', uri: URI.file(source).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceCopy recursively copies a directory', async function () { + const clientId = await initializeClient('copy-directory'); + const workspace = createWorkspace('ahp-client-hosted-copy-directory-'); + const source = join(workspace, 'source'); + const destination = join(workspace, 'destination'); + mkdirSync(join(source, 'nested'), { recursive: true }); + writeFileSync(join(source, 'nested', 'file.txt'), 'copied-tree'); + + await context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + }); + + assert.strictEqual(readFileSync(join(destination, 'nested', 'file.txt'), 'utf8'), 'copied-tree'); + assertReverseRequest({ method: 'resourceCopy', uri: URI.file(source).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceCopy failIfExists preserves the destination', async function () { + const clientId = await initializeClient('copy-existing'); + const workspace = createWorkspace('ahp-client-hosted-copy-existing-'); + const source = join(workspace, 'source.txt'); + const destination = join(workspace, 'destination.txt'); + writeFileSync(source, 'source'); + writeFileSync(destination, 'destination'); + + await assert.rejects(context.client.call('resourceCopy', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + failIfExists: true, + }), { code: AhpErrorCodes.AlreadyExists }); + + assert.deepStrictEqual({ + source: readFileSync(source, 'utf8'), + destination: readFileSync(destination, 'utf8'), + }, { + source: 'source', + destination: 'destination', + }); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(destination).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceMove relocates a file', async function () { + const clientId = await initializeClient('move-file'); + const workspace = createWorkspace('ahp-client-hosted-move-file-'); + const source = join(workspace, 'source.txt'); + const destination = join(workspace, 'nested', 'destination.txt'); + writeFileSync(source, 'moved'); + + await context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + }); + + assert.deepStrictEqual({ sourceExists: existsSync(source), destination: readFileSync(destination, 'utf8') }, { + sourceExists: false, + destination: 'moved', + }); + assertReverseRequest({ method: 'resourceMove', uri: URI.file(source).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceMove relocates a directory tree', async function () { + const clientId = await initializeClient('move-directory'); + const workspace = createWorkspace('ahp-client-hosted-move-directory-'); + const source = join(workspace, 'source'); + const destination = join(workspace, 'destination'); + mkdirSync(join(source, 'nested'), { recursive: true }); + writeFileSync(join(source, 'nested', 'file.txt'), 'moved-tree'); + + await context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + }); + + assert.deepStrictEqual({ + sourceExists: existsSync(source), + destination: readFileSync(join(destination, 'nested', 'file.txt'), 'utf8'), + }, { + sourceExists: false, + destination: 'moved-tree', + }); + assertReverseRequest({ method: 'resourceMove', uri: URI.file(source).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceMove failIfExists preserves both resources', async function () { + const clientId = await initializeClient('move-existing'); + const workspace = createWorkspace('ahp-client-hosted-move-existing-'); + const source = join(workspace, 'source.txt'); + const destination = join(workspace, 'destination.txt'); + writeFileSync(source, 'source'); + writeFileSync(destination, 'destination'); + + await assert.rejects(context.client.call('resourceMove', { + channel: ROOT_STATE_URI, + source: clientUri(clientId, source), + destination: clientUri(clientId, destination), + failIfExists: true, + }), { code: AhpErrorCodes.AlreadyExists }); + + assert.deepStrictEqual({ + source: readFileSync(source, 'utf8'), + destination: readFileSync(destination, 'utf8'), + }, { + source: 'source', + destination: 'destination', + }); + assertReverseRequest({ method: 'resourceResolve', uri: URI.file(destination).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceDelete removes a file', async function () { + const clientId = await initializeClient('delete-file'); + const workspace = createWorkspace('ahp-client-hosted-delete-file-'); + const file = join(workspace, 'delete.txt'); + writeFileSync(file, 'delete'); + + await context.client.call('resourceDelete', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, file), + }); + + assert.strictEqual(existsSync(file), false); + assertReverseRequest({ method: 'resourceDelete', uri: URI.file(file).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceDelete recursively removes a directory tree', async function () { + const clientId = await initializeClient('delete-directory'); + const workspace = createWorkspace('ahp-client-hosted-delete-directory-'); + const directory = join(workspace, 'delete'); + mkdirSync(join(directory, 'nested'), { recursive: true }); + writeFileSync(join(directory, 'nested', 'file.txt'), 'delete-tree'); + + await context.client.call('resourceDelete', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, directory), + recursive: true, + }); + + assert.strictEqual(existsSync(directory), false); + assertReverseRequest({ method: 'resourceDelete', uri: URI.file(directory).toString() }); + }); + + conformanceTest(context, 'client-hosted resourceDelete rejects a non-empty directory without recursive mode', async function () { + const clientId = await initializeClient('delete-non-recursive'); + const workspace = createWorkspace('ahp-client-hosted-delete-non-recursive-'); + const directory = join(workspace, 'preserve'); + mkdirSync(directory); + writeFileSync(join(directory, 'file.txt'), 'preserve'); + + await assert.rejects(context.client.call('resourceDelete', { + channel: ROOT_STATE_URI, + uri: clientUri(clientId, directory), + })); + + assert.strictEqual(readFileSync(join(directory, 'file.txt'), 'utf8'), 'preserve'); + assertReverseRequest({ method: 'resourceList', uri: URI.file(directory).toString() }); + }); +} diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 1c664a3059cf91..308abd7dbc5eda 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -48,9 +48,11 @@ import { MessageKind, buildDefaultChatUri, mergeSessionWithDefaultChat, parseDef import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentEnabledEnvVar } from '../../common/agentService.js'; import { + AhpErrorCodes, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, + JsonRpcErrorCodes, ProtocolError, type AhpNotification, type JsonRpcNotification, @@ -206,12 +208,14 @@ export class TestProtocolClient { this._ahpSnapshot.record('c2s', response); this._ws.send(JSON.stringify(response)); } catch (error) { + const protocolError = this._toReverseRequestProtocolError(error); const response: JsonRpcErrorResponse = { jsonrpc: '2.0', id: msg.id, error: { - code: -32603, - message: error instanceof Error ? error.message : String(error), + code: protocolError.code, + message: protocolError.message, + data: protocolError.data, }, }; this._ahpSnapshot.record('c2s', response); @@ -219,6 +223,29 @@ export class TestProtocolClient { } } + private _toReverseRequestProtocolError(error: unknown): ProtocolError { + if (error instanceof ProtocolError) { + return error; + } + const errorCodeValue: unknown = error instanceof Error ? Object.getOwnPropertyDescriptor(error, 'code')?.value : undefined; + const errorCode = typeof errorCodeValue === 'string' ? errorCodeValue : undefined; + const message = error instanceof Error ? error.message : String(error); + switch (errorCode) { + case 'ENOENT': + case 'ENOTDIR': + return new ProtocolError(AhpErrorCodes.NotFound, message); + case 'EACCES': + case 'EPERM': + return new ProtocolError(AhpErrorCodes.PermissionDenied, message); + case 'EEXIST': + return new ProtocolError(AhpErrorCodes.AlreadyExists, message); + case 'ENOTEMPTY': + return new ProtocolError(AhpErrorCodes.Conflict, message); + default: + return new ProtocolError(JsonRpcErrorCodes.InternalError, message); + } + } + private _isReverseRequestMethod(method: string): method is ReverseRequestMethod { switch (method) { case 'createResourceWatch': @@ -361,11 +388,7 @@ export class TestProtocolClient { const createOnly = params.createOnly ?? false; await mkdir(dirname(filePath), { recursive: true }); - const exists = await this._pathExists(filePath); - if (createOnly && exists) { - throw new Error(`File already exists: ${filePath}`); - } - const existing = exists ? await readFile(filePath) : Buffer.alloc(0); + const existing = !createOnly && await this._pathExists(filePath) ? await readFile(filePath) : Buffer.alloc(0); const clampedStart = Math.min(position, existing.length); let next: Buffer; switch (mode) { @@ -382,7 +405,7 @@ export class TestProtocolClient { next = Buffer.concat([existing.subarray(0, clampedStart), incoming]); break; } - await writeFile(filePath, next); + await writeFile(filePath, next, createOnly ? { flag: 'wx' } : undefined); return {}; } @@ -405,7 +428,7 @@ export class TestProtocolClient { const destination = this._assertFileUri(this._coerceUri(params.destination)); const failIfExists = params.failIfExists ?? false; if (failIfExists && await this._pathExists(destination)) { - throw new Error(`Destination already exists: ${destination}`); + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination}`); } await mkdir(dirname(destination), { recursive: true }); await rename(source, destination); @@ -417,7 +440,7 @@ export class TestProtocolClient { const destination = this._assertFileUri(this._coerceUri(params.destination)); const failIfExists = params.failIfExists ?? false; if (failIfExists && await this._pathExists(destination)) { - throw new Error(`Destination already exists: ${destination}`); + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination}`); } await mkdir(dirname(destination), { recursive: true }); await cp(source, destination, { recursive: true, force: !failIfExists, errorOnExist: failIfExists }); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 3796ecef8a51b8..561f8230710dff 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -205,6 +205,21 @@ export function managedModelValue(): (policyData: IPolicyData) => ManagedSetting return managedModelValueCallback; } +/** + * `value` callback shared by the third-party agent harness policies (`Claude3PIntegration`, + * `Codex3PIntegration`): forces the harness off when the account disables chat preview features, + * or when the user is governed by managed settings at all. + * + * Managed settings are composed and enforced by the Copilot runtime and never reach the Claude or + * Codex harnesses, so leaving them available would hand a governed user an ungoverned path around + * every managed control the enterprise set. + */ +export function thirdPartyAgentEnabledValue(policyData: IPolicyData): boolean | undefined { + return policyData.chat_preview_features_enabled === false || policyData.managedSettingsActive === true + ? false + : undefined; +} + export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); export interface INativeManagedSettingsService { diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index aab69e83fcdee0..1dcdc4432e54b0 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,399 +1,185 @@ -# AI Customizations – Design Document +# AI customizations architecture -This document describes the AI customization experience: a management editor and tree view that surface customization items (agents, skills, instructions, prompts, hooks, MCP servers) across workspace, user, and extension storage. +> **Specification change gate:** Do not update this document for UI changes, +> migrations, discovery fixes, or race handling. Update it only when shared +> ownership, an interface, the item pipeline, or harness semantics changes. -## Architecture +## Scope -### File Structure +The AI customizations experience discovers and manages agents, skills, +instructions, prompts, hooks, MCP servers, tools, and plugins across workspace, +user, extension, built-in, and external sources. -The management editor lives in `vs/workbench` (shared between core VS Code and sessions): +This specification defines stable ownership and extension contracts shared by +the editor workbench and Agents Window. Individual controls, migration flows, +copy, styling, and bug behavior belong in code, component fixtures, and focused +tests. -``` -src/vs/workbench/contrib/chat/browser/aiCustomization/ -├── aiCustomizationManagement.contribution.ts # Commands + context menus -├── aiCustomizationManagement.ts # IDs + context keys -├── aiCustomizationManagementEditor.ts # SplitView list/editor -├── aiCustomizationManagementEditorInput.ts # Singleton input -├── aiCustomizationListWidget.ts # Search + grouped list -├── aiCustomizationItemsModel.ts # IAICustomizationItemsModel: aggregated item model + section counts -├── aiCustomizationItemSource.ts # Item pipeline: ICustomizationItem → IAICustomizationListItem view model -├── aiCustomizationWelcomePage.ts # Welcome page host (AICustomizationWelcomePage + implementation interface) -├── aiCustomizationWelcomePagePromptLaunchers.ts # Welcome page implementation: prompt launchers -├── embeddedMcpServerDetail.ts # Inline MCP server detail panel -├── embeddedAgentPluginDetail.ts # Inline agent plugin detail panel -├── promptsServiceCustomizationItemProvider.ts # Adapts IPromptsService → ICustomizationItemProvider -├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) -├── aiCustomizationDebugPanel.ts # Debug diagnostics panel -├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl -├── customizationHarnessService.ts # Core harness service impl (agent-gated) -├── customizationCreatorService.ts # AI-guided creation flow -├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer -├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) -├── pluginListWidget.ts # Agent plugins section -├── aiCustomizationIcons.ts # Icons -└── media/ - └── aiCustomizationManagement.css # Management editor styling, including Sessions empty-state layout - -src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE -└── customizationHarnessService.ts # ICustomizationHarnessService + ICustomizationItem + ICustomizationItemProvider + helpers -``` - -The tree view and overview live in `vs/sessions` (agent sessions window only): - -``` -src/vs/sessions/contrib/aiCustomizationTreeView/browser/ -├── aiCustomizationTreeView.contribution.ts # View + actions -├── aiCustomizationTreeView.ts # IDs + menu IDs -├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) -└── media/ - └── aiCustomizationTreeView.css -``` - -Sessions-specific overrides: - -``` -src/vs/sessions/contrib/chat/browser/ -├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) -└── promptsService.ts # AgenticPromptsService (CLI user roots) -src/vs/sessions/contrib/sessions/browser/ -├── aiCustomizationShortcutsWidget.ts # Resizable sidebar shortcuts widget with overview + section links -└── customizationsToolbar.contribution.ts # Sidebar customization links -``` - -### Management Editor Shell - -The management editor opens as a compact modal editor. The modal title and welcome page heading use `Agent Customizations for {harness label}` so the active harness is visible throughout the overview experience. If no harness descriptor is available yet, the UI falls back to `Local`. - -The first sidebar entry is a static `Overview` navigation item. It is styled like the other sidebar labels and does not mirror the active harness label; harness identity is represented by the modal title and welcome heading instead. - -The Tools section can browse the Marketplace in the core workbench, where extension gallery browsing and installation are available. The Sessions window hides Tools Marketplace browsing and only shows the tool enablement list. - -The Plugins section keeps plugin maintenance close to plugin creation: its compact toolbar includes an accessible Update Plugins button beside Create Plugin. This invokes the shared `workbench.agentPlugins.checkForUpdates` command, matching the Update Plugins action in the installed Agent Plugins view title; holding Alt/Shift on that view-title action invokes the existing force-update command. Update actions are disabled while the shared operation is running. Progress is shown while checking, followed by a notification listing updated or failed plugins, or confirming that plugins are already up to date. - -Agent Host MCP **Show Output** actions prepare and register their target channel, close the modal management editor, then reveal the prepared channel. Closing before preparation can tear down the active harness context, while showing before close lets modal teardown reset the Output presentation. - -When the active harness is an agent host (`agent-host-*` / `remote-*`), the editor can offer **two separate, focused migrations**. Each is its own category with its own experimental setting, overview card, sidebar shortcut, page, copy, and confirmation, because they are different operations: one *converts* file types, the other only *relocates* files. Categories are non-overlapping, so no file is ever offered twice. - -- **Migrate Prompt Files** (`chat.customizations.promptMigration.enabled`) — appears when the core `IPromptsService` discovers workspace or user `*.prompt.md` files, which agent-host harnesses ignore. It converts selected prompt files into skills under the harness-appropriate skill roots (for example `.github/skills` / `~/.copilot/skills` for Copilot, `.claude/skills` / `~/.claude/skills` for Claude) and preserves manual invocation by setting `disable-model-invocation: true`. Its page groups by **Workspace** and **User**. -- **Migrate User Data Customizations** (`chat.customizations.userDataMigration.enabled`) — appears when agents or instructions are found in the profile's User Data `promptsHome` (`PromptFileSource.UserData`), which only VS Code reads. These files keep their type and content and move to the active harness's global agents or instructions root. Its page groups by **Agents** and **Instructions**. User Data prompt files are deliberately left to the prompt migration so every prompt file is converted in one place. - -This page leads with a banner rather than a one-line description, because the migration has a consequence worth stating before the user commits: `promptsHome` is synced by `promptsSync`, so `.agent.md` and `.instructions.md` files there roam between devices with Settings Sync. Once migrated they live on one machine only. The banner names the trade so the choice is made knowingly, and is supplied by the category via the optional `getBanner` descriptor hook — a category that returns one has its page description suppressed to avoid repeating itself. - -The documentation link follows the migration note so the page reads in decision order: what this migration is, the consequence, then where to learn more. - -The two settings are independent: enabling one does not surface the other, and candidates are only scanned for enabled categories, so a disabled migration costs no prompt-file discovery. Each category declares its own `enablementSetting` on its descriptor, so adding a future migration means adding a descriptor rather than touching the editor. - -Both pages share the same machinery: search, per-item and per-group selection, independently collapsible groups, opening a file before migrating, deleting an obsolete file, an opt-out for deleting originals, collision-safe target names, and partial-failure reporting. Candidates are offered only when the active session's harness provides a writable destination folder for that customization type and storage, so harnesses without agent or instruction roots never offer the User Data migration; candidate discovery reruns when the active session changes even if its harness type stays the same. Destination resolution and confirmation remain bound to the initiating session and stop if another session becomes active, while deleting a candidate preserves destination metadata for the remaining rows. Toggling a group checkbox updates its item checkboxes in place so keyboard focus is preserved, and individual selection changes keep the group checkbox synchronized. Selection identity includes both URI and storage because one physical file can be configured as both workspace and user storage; the two rows remain independently selectable. Opening a candidate uses the shared `Button` widget around its name and path, leaving the checkbox and delete action as separate keyboard targets. Its accessible name includes both visible labels so same-named files remain distinguishable to screen-reader users. +## Ownership -The User Data migration names the resolved destination folder in its banner and confirmation. The banner clarifies that files moved there remain available to both VS Code and the selected harness, and accurately notes that those files are not currently included in Settings Sync without recommending that users commit a broader harness data directory. +The shared management editor and contracts live under: -Agent-host component fixtures provide writable source folders for agents, instructions, and skills so migration availability and destination copy are exercised instead of rendering an unsupported-harness empty state. +- `vs/workbench/contrib/chat/browser/aiCustomization/`; +- `vs/workbench/contrib/chat/common/`. -Migration is transactional per source URI. All selected storage identities for one source are copied before the original is deleted once. Targets are created with overwrite disabled and become rollback-owned only after creation succeeds, so a conflicting pre-existing target is preserved. If any target creation or the source deletion fails, every target created by this migration for that source is rolled back, so retrying does not create suffixed duplicates. When a destination type exposes multiple matching roots, migration prompts once for that target and reuses it for every selected file of that type and storage. +The Agents Window contributes: -Migration overview cards use their native action button as the only interactive target; the surrounding card is presentational rather than a focusable button containing another button. The full User Data migration page fixture is `blocksCi` because its warning and migration controls form a distinct full-page state. +- the customizations tree and overview under + `vs/sessions/contrib/aiCustomizationTreeView/`; +- Sessions-specific workspace and harness adapters under + `vs/sessions/contrib/chat/`; +- Sessions sidebar entry points under `vs/sessions/contrib/sessions/`. -Automation run history stores the created session as a serialized URI. Its Open Session action uses the shared resource-first session opener, allowing the Agents window to route the URI through `ISessionsService` before the core workbench falls back to resolving an `IAgentSession`. +Shared workbench code owns reusable discovery and management behavior. Sessions +code adapts active-session context and provider-backed harnesses without adding +Sessions dependencies to `vs/workbench`. -Manual automation runs announce that they started once session dispatch commits, while lifecycle tracking continues until completion, failure, cancellation, or timeout. +## Service boundary -Automations use a discriminated target that is either workspace-backed or a workspace-less quick chat. The workspace dropdown owns both choices: selecting **No workspace** switches to the existing quick-chat provider/session-type catalog, while selecting a folder restores repository configuration. Workspace-less targets display and announce as `without a workspace` in the list and cannot carry folder, isolation, or branch configuration; workspace-backed targets require a folder, with Worktree isolation requiring its base branch. The automation dialog suppresses its root outline for pointer focus while preserving keyboard-visible focus indication. Ledger schema v3 persists this target union and migrates schema-v1/v2 flat records while preserving valid workspace-backed targets. A successful authoritative CAS updates in-memory state even when restored storage resets the revision counter, while lower-revision change notifications cannot roll observables backward. +### `IAICustomizationWorkspaceService` -The Agents window contributes a built-in **Automations** client-tool set with `listAutomations`, `configureAutomation`, `runAutomation`, and `deleteAutomation`. Listing is read-only and returns stable IDs plus editable fields. Configuration uses the invoking session as the default target for new entries and follows the normal tool-approval policy: calls that require interaction show standard tool confirmation, while auto-approved calls proceed directly. Both paths validate and commit through `IAutomationService`, and successful creates and updates return a clickable chat result that opens the affected automation. `runAutomation` uses the same approval policy, starts a manual run through `IAutomationRunner` even when scheduled runs are disabled, and returns after dispatch with the run and session identifiers while lifecycle tracking continues in the background; an already-active run or unavailable target is reported without claiming a new run started. A run slot is claimed atomically: `recordRunStart` re-checks for an active run inside the same CAS that appends the pending run, so concurrent manual triggers from agents, the **Run now** button, or separate windows cannot both start the same automation, and only the caller that wins the swap dispatches a session. Manual workspace choices in the automation dialog never update the new-session recent-workspace list. Deletion uses **Delete**/**Cancel** confirmation when required, removes the automation and retained run history, and lets already-dispatched sessions continue. Denial, invalid IDs, stale confirmed updates, and cancellation or disablement observed by the mutation guard leave the ledger unchanged. The guard runs immediately before every CAS attempt; once an atomic CAS starts, concurrent cancellation or disablement cannot revoke a committed write, and the tool reports that commit as successful. +This service supplies per-window policy to the shared editor: -For Agent Host client tools, a call made while the SDK is in **Allow all** mode carries `autoApproveBySetting` on its ready action. A plain `not-needed` confirmation reason is insufficient because client tools that did not consult the setting can use the same reason. +- available management sections; +- whether the surface is in the Agents Window; +- the active project root; +- welcome-page capabilities. -### IAICustomizationWorkspaceService +The editor workbench resolves project context from its workspace. The Agents +Window resolves it from the scoped active session. -The `IAICustomizationWorkspaceService` interface controls per-window behavior: +### `ICustomizationHarnessService` -| Property / Method | Core VS Code | Agent Sessions Window | -|----------|-------------|----------| -| `managementSections` | All sections except Models | All sections except Models | -| `isSessionsWindow` | `false` | `true` | -| `activeProjectRoot` | First workspace folder | Active session worktree | -| `welcomePageFeatures` | Shows getting-started banner + per-card AI actions | Shows getting-started banner, hides per-card AI actions | +A harness represents the execution environment that consumes customizations. +Storage answers where an item came from; a harness answers which runtime can use +it. -### ICustomizationHarnessService +The service owns: -A harness represents the AI execution environment that consumes customizations. -Storage answers "where did this come from?"; harness answers "who consumes it?". +- registered harness descriptors; +- the active harness; +- dynamic external harness registration; +- harness-specific item and enablement providers. -The service is defined in `common/customizationHarnessService.ts` which also provides: -- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list -- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[AICustomizationSources.extension, AICustomizationSources.builtin]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[AICustomizationSources.builtin]`. -- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. -- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. +Core workbench registrations may expose Local, Copilot CLI, and Claude harnesses +when their backing agents are available. The Agents Window exposes harnesses +backed by registered session content providers and does not assume a Local +fallback. -Available harnesses: +### `IHarnessDescriptor` -| Harness | Label | Description | -|---------|-------|-------------| -| `vscode` | Local | Shows all storage sources (default in core) | -| `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | -| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | +Descriptors declare presentation and discovery policy. Widgets consume the +descriptor rather than branching on a harness identifier. -In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. -In sessions, the Local harness is not registered. Harnesses are accepted for any session type that has a registered content provider (checked via `IChatSessionsService.getContentProviderSchemes()`). The first provider harness becomes active until a session selects its own harness, and the editor uses no Local fallback label while none is available. AHP remote servers register directly via `registerExternalHarness`. +A descriptor may define: -Remote agent hosts can also register **external harnesses** dynamically. Each remote agent harness may contribute: -- an `itemProvider` that surfaces plugins already configured on the remote host (or synced into the active remote session), -- a `disableProvider` that lets users opt out individual files/plugins from auto-sync, and -- `pluginActions` that add environment-specific commands such as "Add Remote Plugin" to the Plugins section add menu alongside the default install-from-source action. The create action remains a separate toolbar button. +- visible management sections; +- per-section creation behavior; +- hidden or renamed item types; +- MCP collection exclusions that do not hide host-published servers; +- required agent availability; +- external items, enablement, and plugin actions. -Remote Agent Host registrations auto-sync enabled `PromptsStorage.user` agents, skills, instructions, and prompts from the client in addition to the extension, plugin, and built-in sources shared with local Agent Hosts. Local Agent Hosts exclude user storage from this client bundle because native discovery already reads the same machine's user home. Remote user files are flattened into the existing synthetic Open Plugin, retain their original URI for per-file opt-out, and remain grouped as client-originated after provenance recovery. Host-native user customizations remain separate entries; no client/host precedence or cross-tier deduplication is introduced. Hooks and singleton agent-instruction files such as `~/.claude/CLAUDE.md` and `~/.copilot/copilot-instructions.md` are outside this sync path. +When a new descriptor field is added, update every descriptor factory and both +workbench registrations. -The Plugins section renders remote harness `itemProvider` entries with `type: 'plugin'` directly. This is separate from the prompt-file pipeline used for Agents, Skills, Instructions, Prompts, and Hooks. +### Customization sources -Local plugin discovery is aggregated by `IAgentPluginService` from priority-ordered discovery providers: configured paths, VS Code marketplace installs, extension-contributed plugins, and Copilot CLI installs. Each provider reports `undefined` until its initial scan completes; the service waits for every provider to complete before exposing plugins. Once ready, plugins are canonicalized into collision groups so the same plugin discovered from multiple install roots (for example a VS Code marketplace install and a Copilot CLI direct install) remains visible but only the highest-priority copy is enabled by default. Enabling one copy disables the other copies in the same collision group. Uninstalling a plugin discovered through `chat.pluginLocations` removes its configuration entry without deleting the plugin folder; users can open the folder separately when they want to remove its files. +`AICustomizationSource` distinguishes local, user, extension, plugin, and +built-in items. Source providers and workspace services apply their applicable +discovery policy before view-model grouping. Filtering changes presentation +only; it does not mutate the underlying customization. -Agent Plugins use the portable Agent Plugin layout alongside the existing Copilot, Claude, and Open Plugin adapters. A package is recognized when root `plugin.json` declares an `agent-plugins.org` plugin schema. Compatible schema revisions are accepted, malformed optional metadata is ignored, and a recognized manifest takes precedence over `.plugin/plugin.json`. Agent Plugins contribute only immediate-child `skills/*/SKILL.md` skills and root `mcp.json` servers. They ignore legacy custom paths, inline components, `.mcp.json`, root `SKILL.md`, commands, agents, rules, hooks, LSP servers, and output styles. +## Item pipeline -The shared plugin discovery pipeline selects format-specific component paths while using the same permissive component readers. For Agent Plugins, compatible schema revisions are recognized, known valid manifest fields are retained, fixed `skills/` and `mcp.json` paths are used, and remote servers are normalized for existing MCP transport auto-detection. Discovery preserves unresolved harness-owned values such as `${PLUGIN_DATA}` rather than allocating or interpreting a plugin data directory. Legacy Open Plugin discovery, marketplace/cache/scope behavior, command namespacing, and the synthetic `.plugin/plugin.json` plus `.mcp.json` bundles used for synchronized customizations remain unchanged and do not claim Agent Plugins v1 conformance. Direct root-manifest installation is supported, but Agent Plugins v1 does not define a marketplace protocol. +Customization sources adapt their data into the shared item contract. The +management model aggregates those items, applies harness and storage filters, +and projects list items for the active section. -Runtime projection is provider-specific. Copilot receives strict skills and MCP explicitly rather than through legacy SDK plugin-directory discovery. Codex receives strict skill roots plus MCP, with remote transport selected by its existing auto-detection. Claude excludes strict packages from legacy plugin discovery and can project remote MCP through its existing auto-detection, but its current SDK cannot register external skill directories or provide the per-server working directory required by strict stdio MCP, so those components are reported and skipped. - -Claude Agent Host multi-root customization discovery is gated by the hidden, default-off `chat.agentHost.claudeAgent.multiRootEnabled` setting. When enabled, the primary working directory and each SDK `additionalDirectories` root contribute standalone `.claude/agents`, `.claude/skills`, and native plugin enablement to the Customizations editor. Roots are processed in session order, followed by user scope; same-named standalone agents or skills use the first visible definition as the display source. This display policy is centralized because the SDK reports standalone entries by name rather than source URI. When a standalone agent or skill in one workspace folder is shadowed by a same-named copy in an earlier folder, the dropped copy is logged as a warning because it is unreachable by name (matching the Claude CLI); same-named user-scope copies are ordinary precedence and are not logged. Native plugin loaded state remains authoritative from the SDK snapshot. Rules, hooks, MCP configuration, commands, and CLAUDE.md remain primary-root/user scoped because Claude additional directories do not load those configuration types. Each contributing root has its own writable directory container, and secondary-root watchers observe only agents, skills, and plugin settings. - -### IHarnessDescriptor - -Key properties on the harness descriptor: - -| Property | Purpose | -|----------|--------| -| `itemProvider` | `ICustomizationItemProvider` supplying items; when absent, falls back to `PromptsServiceCustomizationItemProvider` | -| `disableProvider` | `ICustomizationDisableProvider` enabling opt-out of individual items from auto-sync | -| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | -| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | -| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | -| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | -| `requiredAgentId` | Agent ID that must be registered for harness to appear | -| `instructionFileFilter` | Filename/path patterns to filter instruction items | -| `hiddenMcpServerCollectionIds` | Local MCP collections that do not apply to the harness; host-published servers remain visible | - -### IStorageSourceFilter - -A per-type filter controlling which storage sources are visible. - -```typescript -interface IStorageSourceFilter { - sources: readonly PromptsStorage[]; // Which storage groups to display -} +```text +source providers + -> customization item contract + -> harness and storage filtering + -> management model and section counts + -> list/tree presentation ``` -The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. - -**Sessions filter behavior (CLI harness):** - -| Type | sources | -|------|---------| -| Hooks | `[local, plugin]` | -| Prompts | `[local, user, plugin, builtin]` | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | - -**Core VS Code filter behavior:** - -Local harness: all types use `[local, user, extension, plugin, builtin]`. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. Synthetic per-extension tool sets group contributed tools in Chat Customizations and are hidden from the chat tool picker, where the tools are grouped directly by extension. - -Voice customizations follow the same workspace/user split as Copilot instructions but are consumed directly by voice features rather than listed as standard prompt-file sections in the management editor. Voice Mode combines `~/.copilot/voice.md` with each trusted workspace's `.github/voice.md` and sends the result to the backend as `voice_instructions` on both session start and resume. Dictation separately combines `~/.copilot/dictation.md` with each trusted workspace's `.github/dictation.md` and appends the result to its language-model post-processing prompt for terminology and formatting guidance. Separate configure commands create or open either scope and are linked from their respective settings, microphone menus, and the management editor overview. - -CLI harness (core): - -| Type | sources | -|------|---------| -| Hooks | `[local, plugin]` | -| Prompts | `[local, user, plugin]` | -| Agents, Skills, Instructions | `[local, user, plugin]` | - -Claude harness (core): - -| Type | sources | -|------|---------| -| Hooks | `[local, plugin]` | -| Prompts | `[local, user, plugin]` | -| Agents, Skills, Instructions | `[local, user, plugin]` | - -Claude additionally applies: -- `hiddenSections: [Prompts, Plugins]` -- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` -- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) -- `sectionOverrides`: Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension - -Copilot, Claude, and Codex Agent Host harnesses hide the Copilot Chat extension's local GitHub MCP collection because that duplicate is intentionally excluded from synchronization; the provider's host-published GitHub MCP server remains visible. - -### Built-in Extension Grouping (Core VS Code) - -In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". - -`PromptsServiceCustomizationItemProvider` handles this via `applyBuiltinGroupKeys()`: it builds a URI→extension-ID lookup from prompt file metadata, then sets `groupKey: BUILTIN_STORAGE` on items whose extension matches the chat extension ID (checked via the shared `isChatExtensionItem()` utility). The underlying `storage` remains `PromptsStorage.extension` — the grouping is a `groupKey` override that keeps `applyStorageSourceFilter` working while visually distinguishing chat-extension items from third-party extension items. - -`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. - -### Management Editor Item Pipeline - -All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, plugin provenance (`pluginUri`/`pluginLabel`), and status fields. - -``` -promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] - │ -Extension Provider ───────────────────────────────────────→ ICustomizationItem[] - │ -AHP Remote Server ────────────────────────────────────────→ ICustomizationItem[] - │ - ▼ - CustomizationItemSource (aiCustomizationItemSource.ts) - ├── normalizes → IAICustomizationListItem[] - ├── expands hooks from file content - └── normalizes items from provider - │ - ▼ - List Widget renders -``` - -**Key files:** - -- **`aiCustomizationItemSource.ts`** — The browser-side pipeline: `IAICustomizationListItem` (view model), `IAICustomizationItemSource` (data contract for both customization rows and harness-provided source folders), `AICustomizationItemNormalizer` (maps `ICustomizationItem` → view model, inferring storage/grouping from URIs when the provider doesn't supply them), `ProviderCustomizationItemSource` (orchestrates provider + sync + normalizer), and shared utilities (`expandHookFileItems`, `getFriendlyName`, `isChatExtensionItem`). - -- **`promptsServiceCustomizationItemProvider.ts`** — Adapts `IPromptsService` to `ICustomizationItemProvider`. Reads agents, skills, instructions, hooks, and prompts from the core service, expands instruction categories and hook entries, applies harness-specific filters (storage sources, workspace subpaths, instruction file patterns), and returns `ICustomizationItem[]` with `storage` set from the authoritative promptsService metadata. Used as the default item provider for harnesses that don't supply their own. - -- **`customizationHarnessService.ts`** (common layer) — Defines `ICustomizationItem`, `ICustomizationItemProvider`, `ICustomizationDisableProvider`, and `IHarnessDescriptor`. A harness descriptor optionally carries an `itemProvider`; when absent, the widget falls back to `PromptsServiceCustomizationItemProvider`. - -- **`customizationMigration.ts`** — Shared, category-agnostic migration mechanics: prompt-to-skill content conversion, same-type relocation for other customizations, collision-safe target naming, and the per-file migrate/write/delete workflow with partial-failure reporting. -- **`customizationMigrationCategories.ts`** — The focused migration categories (Prompt Files, User Data). Each descriptor owns its candidate predicate, grouping, enablement setting, and complete localized copy, so the editor renders both flows from one generic page without harness- or category-specific conditionals. - -### MCP server list active-session controls - -The MCP Servers tab merges local/workspace MCP configuration with MCP servers reported by the active agent-host session. When a listed server also exists in the active session, row status follows the session-backed server and lifecycle controls (start/stop) target the agent host. Model-access and sampling-log actions are hidden for session-backed rows because those are not inline session controls. Runtime states render as semantic colored icons rather than text badges: running uses a green check, while stopped has no visual icon. Authentication-required rows expose an inline **Sign In** button, and an actionable error icon opens that server's local or agent-host output. - -For agent-host sessions, the client publishes every known plugin and VS Code-owned MCP server with an explicit global decision derived only from the VS Code profile. The host owns durable workspace and session decisions and resolves their effective enablement. Bundled MCP servers carry their decision by child name because the host discovers them from the synthetic plugin's `.mcp.json`. A session action dispatches only a session decision; the temporary non-session action dispatches a global decision until the full scoped action matrix is available. - -### Structured Detail Preview - -For markdown-backed customizations (`.agent.md`, `SKILL.md`, `.instructions.md`, `.prompt.md`), the management editor opens a **structured preview** by default instead of showing the raw file immediately. - -- The preview parses the file with `PromptFileParser` -- Header metadata is rendered as labeled rows -- Each row includes an inline help affordance whose hover text comes from `getAttributeDefinition(...)` -- The markdown body is rendered via `IMarkdownRendererService` -- A header button switches between the structured preview and the raw editor/viewer - -Hooks and other non-markdown detail views continue to open directly in their existing raw/detail experiences. - -### AgenticPromptsService (Sessions) - -Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): - -- **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree -- **Built-in skills**: Discovers bundled `SKILL.md` files from `vs/sessions/skills/{name}/` and surfaces them with `PromptsStorage.builtin` storage type -- **User override**: Built-in skills are omitted when a user or workspace skill with the same name exists -- **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility -- **Hook folders**: Falls back to `.github/hooks` in the active worktree - -### Built-in Skills - -All built-in customizations bundled with the Sessions app are skills, living in `src/vs/sessions/skills/{name}/SKILL.md`. They are: - -- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/skills')` -- Tagged with `PromptsStorage.builtin` storage type -- Shown in a "Built-in" group in the AI Customization tree view and management editor -- Filtered out when a user/workspace skill shares the same name (override behavior) -- Skills with UI integrations (e.g. `act-on-feedback`, `generate-run-commands`) display a "UI Integration" badge in the management editor - -#### Enabling and Disabling Built-in Skills - -The **Enable** / **Disable** actions on a built-in skill persist to `IPromptsService.setDisabledPromptFiles(PromptsType.skill, …)` (profile-scoped storage). This is a distinct store from the per-harness auto-sync opt-out owned by `ICustomizationSyncProvider`, which the Plugins section writes. - -The two stores are consulted at different points, and deliberately not identically: - -- **The wire** honors *both*. `enumerateLocalCustomizationsForHarness` marks a file disabled when either store opts it out, so a disabled skill is excluded from the synthetic Open Plugin bundle and never reaches the agent host. -- **The list** derives `enabled` from the prompts-service store *only*. `mergeBuiltinSkills` ignores the sync-provider store because that store holds **plugin** URIs — its sole writer is the Plugins section checkbox, and `isDisabled` matches URIs exactly rather than by containment — so it can never opt out an individual built-in skill. If a per-file sync opt-out is ever added, this derivation must account for it; otherwise a skill dropped from the wire would be re-listed as enabled, and the **Enable** action (which writes only the prompts store) could not correct it. - -Two places must consult the prompts-service store for the toggle to take effect on an agent-host harness: - -- **The wire.** As above — the skill is excluded from the bundle. -- **The list.** Because a disabled skill is no longer in the bundle, the agent-host item provider stops reporting it. `PureItemProviderItemSource` therefore merges built-in skills in from `IPromptsService.listPromptFilesForStorage(skill, builtIn)` (via the shared `mergeBuiltinSkills` helper, deduped by URI against provider rows) and derives their `enabled` state from `getDisabledPromptFiles`. This keeps a disabled built-in listed — greyed out, with an **Enable** action — instead of vanishing with no way to restore it. Its `onDidAICustomizationItemsChange` includes `onDidChangeSkills` so the row updates immediately. - -`ItemProviderItemSource` (non-agent-host harnesses) uses the same helper, so both paths group, dedupe, and gate built-ins identically. - -##### Scope: only built-in skills may be hidden by the user-disabled store - -The wire consults `getDisabledPromptFiles` **only** for the `(type, storage)` combination the Customizations UI can re-enable, expressed by `isUserToggleableCustomization` in `chat/common/promptSyntax/service/promptsService.ts`. Both the management editor and the sessions tree view register their Enable/Disable actions solely for built-in skills, so that is the only toggleable combination today. - -This gate is load-bearing rather than cosmetic. `getDisabledPromptFiles` is a shared store that the chat view agent picker also writes for `PromptsType.agent` ("hidden from agent picker"). Because callers drop opted-out files from the bundle entirely and the Agents-window lists are derived from that bundle, honoring the store for a customization the Customizations UI cannot re-enable would strand it: the row disappears, and the **Enable** action that would bring it back is only rendered for rows that are still listed. The agent picker is unaffected — it owns its own unhide affordance and does not read from the bundle. - -Consequently, the wire gate and `mergeBuiltinSkills` must be kept in sync: anything the wire is allowed to hide must have a corresponding restore path in the list. - -### UI Integration Badges - -Skills that are directly invoked by UI elements (toolbar buttons, menu items) are annotated with a "UI Integration" badge in the management editor. The mapping is provided by `IAICustomizationWorkspaceService.getSkillUIIntegrations()`, which the Sessions implementation populates with the relevant skill names and tooltip descriptions. The badge appears on both the built-in skill and any user/workspace override, ensuring users understand that overriding the skill affects a UI surface. - -### Count Consistency - -Counts shown in the sidebar (per-link badges and the header total in `AICustomizationShortcutsWidget`) are driven by the same `IAICustomizationItemsModel` singleton (`workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts`) that feeds the customizations editor's list widget. The model owns the per-active-harness `ProviderCustomizationItemSource` cache and exposes per-section `IObservable`; sidebar consumers `read` `.length` from those observables. There is exactly one discovery path, so editor and sidebar counts cannot diverge. McpServers use `IMcpService.servers` directly. Plugins use `IAICustomizationItemsModel.getPluginCount()`, which combines locally installed plugins from `IAgentPluginService.plugins` with plugin rows supplied by the active remote customization provider. - -Provider-supplied customization rows that include an explicit storage origin are treated as authoritative even when no local URI inference is available. In particular, `storage: PromptsStorage.plugin` keeps AHP remote host plugin customizations out of the User group when no local `pluginUri` exists, and `storage: BUILTIN_STORAGE` keeps provider-supplied built-ins in the Built-in group. - -### MCP Active Session Status - -The MCP Servers section combines locally known MCP servers with MCP servers reported by the active agent-host session (`IAgentHostCustomizationService.getMcpServers(activeSessionResource)`). Active-session servers are matched to known workspace, user, extension, plugin, or built-in rows by stable identifiers and display names so the row can show the active session's status, matching `MCP: List Servers`. Active-session servers that do not match any known local/runtime server are appended to the **Workspace** group and counted with the rest of the section. - -The MCP list uses `WorkbenchList` as its sole scroll owner. Layout uses the widget's rendered content-box dimensions rather than the padded panel's outer dimensions, and the virtual delegate height matches each rendered row variant, including the taller two-line description row. These invariants keep the final row fully reachable at the bottom of the list. - -### Sidebar Customizations Section +Section counts and rendered rows consume the same filtered model so hidden or +disabled sources cannot appear in one surface but not the other. -The Agents sidebar `AICustomizationShortcutsWidget` appears as a collapsible, vertically resizable section below the sessions list. Its resize sash is the horizontal separator above the section and uses the same `SplitView` styling as the Checks section in the changes view, with a 4px separator and sash inset on each side. The section's expanded minimum height is 129px, while its initial and maximum height are capped to the rendered content height so the pane does not open with empty space. When collapsed, the section shrinks to its header height and shows the total customization count to the left of the hover-revealed chevron. The collapsed/expanded state is persisted per profile (`StorageScope.PROFILE`) and restored on reload. +Prompt-based items use the prompts service adapter. MCP servers, tools, plugins, +and external harness items use their owning providers directly when their data +does not fit the prompt-file contract. -The first sidebar entry is `Overview`, which opens the AI Customization management editor welcome page. The remaining per-category rows deep-link directly to their corresponding management editor section. All entries keep the active customization harness in sync with the active session before opening the editor. +## Active-session context -### Item Badges +In the Agents Window, the customization harness and project root track +`ISessionsService.activeSession`. Opening the editor synchronizes it with the +currently active session, and switching the active session can update the +editor's harness and project context. A transient project-root override takes +precedence while it is set. -`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name. For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. +The management-editor command may select a section, target a session type, and +reveal a URI-addressable customization. Operations that migrate files bind +destination resolution and confirmation to their initiating session and stop if +the active session changes. -### Embedded Detail Editors +Provider-backed items retain provider identity through the shared contract. +Shared widgets must not import or branch on provider implementations. -The management editor opens inline detail panes for prompt files, MCP servers, and plugins. Prompt-file details use the standard text editor pane. MCP and plugin details render dedicated compact widgets — `EmbeddedMcpServerDetail` and `EmbeddedAgentPluginDetail` — purpose-built for the narrow split-pane host. They show the icon, name, scope/source, and description. Do **not** embed the full extension-editor panes inside the split-pane host: they assume a wide page-level layout and don't shrink cleanly. +## External customization providers -The MCP detail fixture in `src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts` must open a real server row (not a group header) and use a local server with concrete config so the compact widget's scope/description rendering is covered by screenshots. +Extensions may contribute customization items through the proposed +`chatSessionCustomizationProvider` API. Its internal contract is +`ICustomizationItemProvider` and `ICustomizationItem`. -### Debug Panel +Changes to that item shape must remain aligned across: -Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a diagnostic view of the item pipeline: +1. the proposed extension API; +2. extension-host protocol DTOs; +3. extension-host mapping; +4. main-thread mapping; +5. the internal customization item. -1. **Provider data** — items returned by the active `ICustomizationItemProvider` -2. **After filtering** — what was removed by storage source and workspace subpath filters -3. **Widget state** — allItems vs displayEntries with group counts -4. **Source/resolved folders** — creation targets and discovery order +New fields should be optional unless the proposal explicitly introduces a +breaking version. -## Key Services +## Enabling and disabling built-in skills -- **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration -- **MCP servers**: `IMcpService` — server list, tool access -- **Active worktree**: `IActiveSessionService` — source of truth for workspace scoping (sessions only) -- **File operations**: `IFileService`, `ITextModelService` — file and model plumbing +Built-in discovery and user enablement are separate stores. Discovery determines +which built-in items exist; enablement records the user's disabled set. Item +projection combines both and keeps the built-in source distinct from extension +and user storage. -Browser compatibility is required — no Node.js APIs. +Harness filtering must happen before enablement presentation so an item hidden +from a harness cannot be reintroduced by its stored enablement state. -## Feature Gating +## Feature gating -All commands and UI respect `ChatContextKeys.enabled`. +Customization surfaces are hidden when AI features are disabled. Contributions +use `ChatContextKeys.enabled` for declarative visibility and the applicable +entitlement state for programmatic hiding. -### Commands +Optional sections and migrations remain behind their owning configuration or +capability. A disabled feature must not perform background discovery solely to +populate hidden UI. -| Command ID | Purpose | -|-----------|---------| -| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link, or an object with `section`, `sessionType`, and `revealUri` | -| `aiCustomization.openMarketplace` | Opens the management editor with marketplace browse mode active. Accepts an optional section (`mcpServers` or `plugins`); defaults to `mcpServers` | +## Testing -### Revealing a Specific Customization +Use focused unit tests for filtering, grouping, counts, and service contracts. +Use component fixtures for layout, section presentation, narrow viewports, and +theme coverage. Cross-window descriptor changes must validate both the editor +workbench and Agents Window registrations. -`aiCustomization.openManagementEditor` accepts a `revealUri` alongside `section`, which selects that section and then reveals and selects the row backed by the URI (`AICustomizationManagementEditor.revealCustomizationByUri`). The reveal retries while the list loads, and clears the search box once so a filtered list cannot hide the target. Only prompt-backed sections have URI-addressable rows; for MCP servers and plugins, selecting the section is the whole reveal. +The executable customization test plan lives in +[test/ai-customizations.test.md](test/ai-customizations.test.md). -The customizations pill above the Agents-window chat input is the main consumer: it lists the customizations a chat used or read and reveals the one the user picks. +## Change policy -## Settings +Update this specification only when ownership, a shared service/interface, the +item pipeline, or harness semantics change. Do not append UI walkthroughs, +migration algorithms, race analyses, file inventories, or regression +narratives. Keep those in tests, short code comments, issues, and pull requests. -User-facing settings use the `chat.customizations.` namespace. Currently, no settings are exposed for the management editor. +The external Copilot runtime discovery snapshot is maintained separately in +[copilot-customizations-spec.md](copilot-customizations-spec.md). diff --git a/src/vs/sessions/LAYERS.md b/src/vs/sessions/LAYERS.md index 02ac00d2c04e63..9716c585b3e16e 100644 --- a/src/vs/sessions/LAYERS.md +++ b/src/vs/sessions/LAYERS.md @@ -1,5 +1,9 @@ # Sessions Layer Rules +> **Specification change gate:** Do not update this document for a bug fix that +> restores the existing import hierarchy. Update it only when the enforced +> layering contract intentionally changes. + This document describes the import layering rules for `src/vs/sessions/`, enforced by the `local/code-import-patterns` ESLint rule. The sessions layer sits above `vs/workbench` in the VS Code source code hierarchy. For the broader VS Code layer rules (base → platform → editor → workbench → sessions), see `.github/instructions/source-code-organization.instructions.md`. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 74a03439e313bf..16a7b2f3877563 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -1,440 +1,174 @@ -# Agents Window Layout +# Agents Window layout -This document describes the layout structure and concepts for the Agents Window workbench. +> **Specification change gate:** Do not update this document for layout bug +> fixes, styling, dimensions, or action placement. Update it only when part +> ownership, workbench topology, or a cross-part contract intentionally changes. ---- +## Scope -## 1. Overview +The Agents Window uses a Sessions-owned workbench layout optimized for agent +work. This specification defines stable part ownership, composition, and +presentation modes. Per-session capture and restoration are owned by +[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md). -The Agents Window workbench (`Workbench` in `sessions/browser/workbench.ts`) provides a simplified, fixed layout optimized for agent session workflows. Unlike the default VS Code workbench, this layout: +Exact dimensions, styling, action placement, and regression behavior belong in +code, design tokens, component fixtures, and focused tests. -- Does **not** support settings-based customization -- Has **fixed** part positions -- Excludes several standard workbench parts (activity bar, status bar, banner) +## Workbench topology ---- - -## 2. Layout Structure - -``` -┌────────────────────────────────────────────────────────────────────────────┐ -│ Titlebar │ -├─────────┬───────────────────────────┬───────────────┬───────────────────────┤ -│ │ Sessions Part │ Editor (hid.) │ Auxiliary Bar │ -│ Sidebar ├───────────────────────────┴───────────────┴───────────────────────┤ -│ │ Panel │ -└─────────┴────────────────────────────────────────────────────────────────────┘ -``` - -The **Sessions Part** is the primary content surface. It hosts an internal grid of one or more **Session Views** (left-to-right) — see [§4 Sessions Part](#4-sessions-part) for the visibility model. - -The Agents window defaults `workbench.editor.useModal` to `some`: editors that require a modal open via `ModalEditorPart`, while ordinary editors open in the main editor part. The main editor part exists in the workbench grid but is hidden until needed. - -### 2.1 Parts - -| Part | Position | Default Visibility | Purpose | -|------|----------|-------------------|---------| -| Titlebar | Top, full width | Always visible | Session picker, toggle actions, account widget | -| Sidebar | Left, below titlebar | Visible | Sessions list | -| Sessions Part | Center of right section | Visible | Grid of one or more session views (each rendering the active chat of its session) | -| Custom View Grid | Same row as the Sessions Part | Hidden | Grid of custom views shown *instead of* the Sessions Part — see [§2.4](#24-custom-view-grid) | -| Editor | In grid, beside Sessions Part | Hidden | Shown for explicit editor workflows | -| Auxiliary Bar | Right side | Visible | Changes view, file tree | -| Panel | Below Sessions Part + Aux Bar | Hidden | Terminal, debug output | - -The Panel and Auxiliary Bar tab strips inherit the shared Modern UI pane-tab presentation from `workbench/contrib/modernUI/browser/media/tabs.css` through the workbench root's `modern-ui-tabs` class. The chat tab strip consumes the reusable editor-tab hooks from the same stylesheet. Sessions-owned styles define only the part surface, inset, and chat-specific adornments; tab geometry, typography, and active/hover/focus states remain owned by the shared editor-tab stylesheet so the Editor and Agents windows stay aligned. - -Clean editor tabs in the Agents window use the compact action-overlay presentation, while tabs with persistent dirty or pinned indicators still reserve the indicator column. The Sessions-owned `EditorParts` enforces `tabActionReserveSpace: false`, independent of the global `workbench.editor.tabActionReserveSpace` preference (which reserves the action column by default), because its editor is commonly shown as a narrow side pane. Other workbench windows reserve the action column by default and may opt out through that preference or `enforcePartOptions()`. - -Standard dialogs retain their full-window modal blocker. The Agents sign-in blocker occupies a lower layer; while it is visible, notification toasts and Quick Input surfaces are placed between it and normal dialogs so authentication progress, its actions, and PAT input remain pointer-interactive without appearing above a later modal. The sign-in dialog also allows Quick Input focus and keybindings through its focus trap, and does not consume the Quick Input's Escape dismissal. - -### 2.2 Grid Tree - -``` -Orientation: VERTICAL (root) -├── Titlebar (leaf, full window width) -└── Content Section (HORIZONTAL) - ├── Sidebar (leaf, 300px default) - └── Right Section (VERTICAL) - ├── Top Right (HORIZONTAL) - │ ├── Sessions Part (leaf, remaining width) - │ ├── Editor (leaf, hidden by default) - │ ├── Auxiliary Bar (leaf, 340px default) - │ └── Custom View Grid (leaf, hidden by default) - └── Panel (leaf, 300px default, hidden) +```text +Title bar +Content +├── Sidebar +└── Main region + ├── Sessions Part | Editor | Auxiliary Bar | Custom View Grid + └── Panel ``` -The titlebar spans the full window width at the root level. Below it, a content section holds the sidebar (left) and the right section. The Sessions Part itself contains an **internal** horizontal grid (one leaf per visible session) — that grid is private to the part and is not part of the workbench grid above. - -The **Sessions Part is the flexible ("remaining width") view** in the top-right row: it has `LayoutPriority.High` so it absorbs auxiliary bar / editor visibility changes and window resizes. The editor and auxiliary bar keep their user-set widths (`LayoutPriority.Normal` / `Low`). Making the editor the high-priority view caused its width to drift to its 300px minimum when the auxiliary bar was toggled across session switches. - -The Sessions Part-to-Editor gap, the gap above the bottom Panel, and the outer right and bottom gutters share `AGENTS_FLOATING_PANEL_GAP` in TypeScript layout and its registered CSS token, `--vscode-agents-layout-floatingPanelGap`. Keeping the outer gutters on the same spacing tier prevents the shell edge from looking more inset than the gaps between parts. Their grid sashes keep the split boundaries unchanged, but expand and shift their hit areas to fill those visual gaps exactly. Each shows the standard persistent three-dot gripper at rest and yields to the full sash highlight while hovered or dragged. The Auxiliary Bar's leading padding and part-internal sashes retain their independent geometry. - -When either the Sessions Part or Editor has been resized to its minimum width, activating that part by pointer or keyboard focus restores it to the available width by resizing its sibling to minimum width. This mirrors minimized editor-group activation while targeting only the Sessions/Editor pair, so the Sidebar and Auxiliary Bar retain their established widths. In single-pane layout the Editor grid node's effective minimum includes the visible docked Auxiliary Bar width, preventing activation from collapsing Details. - -Editor-content overlays must use the editor pane container rather than the editor-group root. In the single-pane layout, the group spans both the editor and the docked detail panel while the pane container is inset to the editor's actual bounds; anchoring feedback controls such as the Submit toolbar to the group would place them over the detail panel. - -### 2.3 Layout Priority Model - -The workbench grid is built with `proportionalLayout: false` (see `createWorkbenchLayout()` in [browser/workbench.ts](src/vs/sessions/browser/workbench.ts)). In this mode the split views do **not** distribute resize deltas proportionally — instead each delta (window resize, or a part being shown/hidden) is absorbed by the highest-`LayoutPriority` view, while the others keep their established sizes. Each part therefore declares an explicit `priority`: - -The single-pane layout preserves the established Sessions/Editor ratio when the outer container dimensions change and the actual Editor area is visible, after the non-proportional grid has laid out its fixed Sidebar and panel. This keeps the two primary horizontal surfaces balanced without allowing the Sidebar or docked Details width to drift; minimum-width constraints still take precedence when the available width is insufficient. - -The shared editor grid node is also visible in Details-only layouts because it hosts the docked Auxiliary Bar. Grid-node visibility must not be treated as Editor visibility when deciding whether to preserve the Sessions/Editor ratio, or a container resize will incorrectly resize the user's Details width. - -Sidebar visibility is intentionally excluded from that proportional adjustment. The Sessions Part is the high-priority view, so collapsing the Sessions list gives all freed width to the Sessions Part while the Editor and docked Details retain their user-set widths; showing the list takes that width back from Sessions. - -| Part | `LayoutPriority` | Width behaviour | -|------|------------------|-----------------| -| Sidebar | `Low` | Fixed user-set width; never absorbs deltas. `minimumWidth` 170 (270 web), `maximumWidth` ∞, snaps closed below the minimum. | -| Sessions Part | **`High`** | Absorbs horizontal deltas in the non-proportional grid. In single-pane, an outer-container resize is post-adjusted to preserve its ratio with a visible Editor. `minimumWidth` 300, `maximumWidth` ∞. | -| Editor | `Normal` | Normally keeps its user-set width (`600` default) and responds to its sash. In single-pane, an outer-container resize also adjusts it proportionally while the actual Editor area is visible. | -| Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | -| Custom View Grid | **`High`** | Claims the whole row. Never visible at the same time as the Sessions Part, so the "exactly one `High` view" invariant below still holds. | - -In the single-pane detail-panel layout, first-run sidebar width is slightly narrower (280px) so a typical window keeps roughly balanced chat and third-pane widths when the pane is shown. Persisted `_savedPartSizes` always win over these defaults. - -**Invariant — exactly one `High` view in the horizontal chain.** A grid branch derives its priority from its children (`BranchNode.priority` in [base/browser/ui/grid/gridview.ts](src/vs/base/browser/ui/grid/gridview.ts)): `High` if any child is `High`, else `Low` if any child is `Low`, else `Normal`. The Top Right row contains a `Low` auxiliary bar, so unless the Sessions Part is `High` the whole Right Section derives to `Low`. The Content Section would then be `Sidebar (Low) | Right Section (Low)` — two equal-priority views — and with no high-priority absorber the resize delta spreads across **both**, growing the sidebar toward half the window. The Sessions Part being `High` is what lifts the Right Section to `High` so it (not the sidebar) absorbs the delta. - -> **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. - -### 2.4 Custom View Grid - -The Custom View Grid (`CustomViewGridPart` in [browser/parts/customViewGridPart.ts](src/vs/sessions/browser/parts/customViewGridPart.ts)) hosts full-surface views that replace the sessions grid — for example a management or dashboard surface that is not tied to a single session. - -**Contract — it is mutually exclusive with the sessions surface.** While a custom view is shown, the Sessions Part, the Editor part *in the grid*, the Auxiliary Bar (side panel) and the Panel (terminal) are all hidden, and vice versa. Only the titlebar and the primary sidebar remain. The *modal* editor part is not affected and may still open over the custom view. - -Which view is shown is owned by `ICustomViewService` ([services/customView/browser/customViewService.ts](src/vs/sessions/services/customView/browser/customViewService.ts)): contributions register an `ICustomViewDescriptor` (id, title, view constructor and optional header actions) and call `showCustomView(id)` / `hideCustomView()`. The workbench observes `activeCustomView` and applies the layout. The desired custom-view id is persisted per workspace and restored when its descriptor registers, so reload returns to the same surface without activating an unavailable view. - -**Desired vs. effective visibility.** The covered parts keep their *desired* visibility in `partVisibility` — showing a custom view only changes what the grid renders (`Workbench._effectiveVisible`). So a layout-controller change made while the custom view is shown (e.g. the user opened a different session in the background) is what gets restored when it is hidden, and `_savePartVisibility` never records the forced-hidden state. `IWorkbenchLayoutService.isVisible` reports the effective value and `onDidChangePartVisibility` fires for the parts whose effective visibility flips, so context keys stay truthful; the layout controller's per-session capture listeners skip those transitions (`_isCustomViewVisible`). - -> **Pitfall:** `SplitView` calls `Part.setVisible` when a view's grid visibility changes, and the workbench maps that event straight back onto the desired visibility (`setSessionsHidden`, `setPanelHidden`, …). The custom view's grid updates therefore run under `_applyingCustomViewGridVisibility`, which makes that listener bail — without it, hiding the parts for a custom view *overwrites* the state that is supposed to be restored, and hiding the custom view leaves neither grid visible. For the same reason, showing a custom view first exits a maximized editor (a maximized editor owns the row instead of the sessions grid) and the grid descriptor is built from the effective values. - -**Dismissal.** Opening a session (`SessionsService._startOpenSession`, which every explicit open gesture funnels through) hides the custom view. On phone layouts showing one pushes a `MobileNavigationStack` layer, so the Android back button dismisses it. Actions that operate on the hidden parts — Toggle Side Panel, Toggle Panel, and the secondary side bar toggle — are disabled while it is shown (`CustomViewVisibleContext`). - -**Chrome.** Each grid leaf is a `CustomViewNode` ([browser/parts/customViewNode.ts](src/vs/sessions/browser/parts/customViewNode.ts)) that owns the shared header — title, optional description and the contributed actions rendered either as an icon toolbar or a button bar — above a scroll container. The header always has a bottom divider, independent of the content's scroll position. The header band and the content are centred and capped to `AGENTS_CENTERED_CONTENT_MAX_WIDTH` (the same measure the session views use); a view may override it with `AbstractCustomView.maxWidth`. Views only fill the content container and are disposed when hidden. On phone-class viewports `CustomViewGridParts` selects `MobileCustomViewGridPart` instead, mirroring `SessionsParts`/`MobileSessionsPart`. - -> **Pitfall:** a custom view that implements `focus()` with its own focus target must not also show the host content's fallback outline. The Automations view suppresses that redundant outer outline while its cards and controls retain their keyboard focus indicators; otherwise clicking the content edge paints a focus ring around the entire view. - -**Card chrome is shared.** The Sessions Part and the Custom View Grid both carry the `agents-part-card` class (`AGENTS_PART_CARD_CLASS`) and use `agentsPartCard.ts` for their metrics, themed colors and content-box math, so their padding, margins, background, border and corner radius are defined once and are identical. - ---- - -## 3. Titlebar - -The titlebar is a standalone implementation (`TitlebarPart`) — not extending `BrowserTitlebarPart`. It has three menu-driven sections: - -| Section | Menu ID | Content | -|---------|---------|---------| -| Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, new session (when sidebar hidden, A/B experiment), agent host filter | -| Center | `Menus.CommandCenter` | Session picker widget | -| Right | `Menus.TitleBarUpdate`, `Menus.TitleBarSessionMenu`, `Menus.TitleBarRightLayout` | The leftmost Update indicator, active-session actions (including Create Pull Request for created sessions with changes), remote connections, run script (split button), Open in VS Code, bottom-panel and auxiliary-bar layout toggles, and the account widget | - -No menubar or `WindowTitle` dependency. Editor-specific actions remain in the editor header, while session-level actions are placed on the right of the title bar. - -The Changes action bar presents its leading primary action as a compact, text-only button; secondary actions retain their icon treatment. The same label-led hierarchy is used when the bar is hosted in the Changes editor header. - -The Update indicator occupies its own toolbar at the leading edge of the right-side action cluster, with trailing spacing that remains when no other action group is visible. Update yields to the task-local Changes primary action whenever both would be visible. At constrained widths, the titlebar measures each of its three sections so content that overflows inside the center grid cannot collide with the right controls. Optional toolbars yield as complete groups: center-adjacent actions and navigation first, then global layout/account actions, active-session actions, and finally Update. The session picker, left toolbar, and native window controls remain stable. - -The account widget shows overlapping provider identities only for accounts that are currently verified as signed in. Its panel keeps provider and status groups in a stable order: Copilot, ChatGPT (or its sign-in action), then contributed account status such as Codebase Semantic Index, with dividers between groups. Subscription usage uses a two-row metric layout with the plan and percentage first, followed by reset timing and the usage label. - -### Session Picker (Center) - -The center section shows a clickable session picker widget. When a session is active it renders: -- **Provider icon** — the session type icon (e.g. Copilot CLI, Cloud) -- **Session title** — the AI-generated or user-assigned session title -- **Workspace name** — the repository or folder name -- **Branch / worktree** — the active git branch or worktree name in parentheses -- **Changes summary** — `+insertions -deletions` when the session has pending changes - -When no session is active (new chat view) the widget hides its chrome so the center is empty. Clicking opens the session switcher quick pick. - -When the primary side bar is hidden and at least one session is **blocked** the widget instead switches to a **requires-input** state (see [Blocked Sessions](#blocked-sessions-center) below). - -After the user approves a pending action on a session from the sessions list (e.g. the **Allow** button on an approval row), the widget briefly shows a green "Approved N sessions" confirmation. Each approval within the rolling 3s window increments the count and restarts the countdown; while visible it takes precedence over the requires-input state. Driven by `ISessionActionFeedbackService` (`contrib/sessions`), whose `approvedCount` observable the widget reads. - -In the single-pane layout, activating the session header **Changes** pill is treated as an explicit -editor open: it reveals the docked editor area and opens the Changes multi-diff editor even though -managed Changes tab activations remain excluded from automatic reveal. - -Editor breadcrumbs omit the workspace-root segment when the Agents Window has one workspace folder. -The active session already establishes the repository/worktree context, so breadcrumbs start at the -first path segment within the workspace (for example, `src > services > session.ts`) instead of -repeating the synthetic `repository (branch)` workspace-folder label. With multiple workspace -folders, breadcrumbs retain the root segment for disambiguation but show only its plain folder name, -without the synthetic branch suffix. The Files view retains the full root labels. - -Workspace-folder presentation is owned by `IWorkspaceFolderLabelService`. The standard workbench -implementation provides no override, leaving the existing URI-derived breadcrumb label unchanged; -the Agents implementation resolves the repository display name from session metadata, returning the -plain repository name for breadcrumbs and the verbose `repository (branch)` form used by workspace -projection and the Files view. - -### Agent Host Filter (Left) - -When multiple remote agent hosts are known, a dropdown pill in the left toolbar scopes the workbench to a specific host. When no hosts are known the pill acts as a re-discover trigger. - -### Blocked Sessions (Center) - -When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to a light orange "N sessions require input" state (orange label with a subtle background and border), and blinks gently twice whenever a newly blocked occurrence appears. A session counts as blocked when it needs input, or - while not in progress - has failing CI checks. Pull request comments do not make a session blocked. Raw detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`), which reuses the shared, background-polled GitHub CI models and identifies CI occurrences by commit. The widget refines this into what the title bar surfaces via the `BlockedSessionsIndicatorModel` (`blockedSessionsIndicatorModel.ts`) it instantiates: it acknowledges the current occurrence when the user views the session or explicitly ignores it, applies optimistic approval dismissals, classifies the homogeneous requires-input reason (for the specific message), builds the pill label, and decides when the attention blink plays. Acknowledgement lasts only for that input request or CI failure; a later approval, a new failing commit, or an unblock-to-block transition surfaces the session again. Clicking the widget opens those sessions rendered exactly like the sessions list but flat - no sections, groups or workspace headers - via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. Its header toolbar offers **Show All Sessions**, **Ignore All Input Needed**, and a trailing **Close** action whose hover shows the `Escape` keybinding. Its rows use `Menus.BlockedSessionsItem` instead of the main session-item toolbar menu and contribute **Ignore Input Needed** / **Ignore CI Failure** actions with the same bell-slash icon. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven by the `BlockedSessionsIndicatorModel`'s `blockedSessions` observable. - -Approval acknowledgement must use the pending tool call's stable id, not the approval model's load-time timestamp. Opening the new-session view can dispose and later reload the chat model; a timestamp-based id would make the same approval appear blocked again after that reload. - -### Account Widget (Right) - -Shows the account profile image, preferring the avatar supplied by the authentication provider for the default account's session, falling back to the public GitHub profile image URL derived from the account name, and finally to the account codicon. Clicking opens a combined account and Copilot status panel with sign-in/sign-out and settings actions. - -### Remote Connections (Right) - -The remote connections toggle is a global titlebar action (`Menus.TitleBarRightLayout`) rather than a per-chat input action. This keeps tunnel hosting state visually scoped to the Agents window as a whole, so users do not interpret it as a setting that must be enabled separately for each chat session. - -This Agents-window placement is intentionally different from the main editor window: outside the Agents window the same toggle remains in `MenuId.ChatInputSecondary` for agent-host chat inputs. Keep both menu items mutually exclusive with `IsSessionsWindowContext` so the editor window keeps its chat-input affordance while the Agents window shows only the titlebar affordance. - ---- - -## 4. Sessions Part - -The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sessions/browser/parts/sessionsPart.ts)) is the central content surface of the Agents window. It does **not** render a chat directly — instead it owns an internal `SerializableGrid` of one or more **session views**. - -### 4.1 Session View - -A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: - -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind, where a session whose isolated worktree is still being created (`ISession.worktreePending`) already shows the worktree icon — plus the workspace label, and a hover showing the working-directory path and git branch (replaced by a "Creating worktree…" note while the worktree is pending, since the reported folder and branch are still those of the checkout the session was started from), registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. While a session's isolated worktree is still being created (`ISession.worktreePending`) the key stays `false`, so the checkout's own changes are never attributed to the session. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button, gated by the per-view `SessionHasPullRequestContext` key, registered from `contrib/github/browser/pullRequestActions.ts`): one pull request renders its live icon + `#`, opens that pull request on activation, and shows the repository/date/title/description/branch hover; several render the first (most recent) pull request's live icon + ` Pull Requests` and open a sticky, keyboard-accessible picker listing each pull request's live state icon, number, and truncated title. The first pull request remains the active projection used by the sessions list, CI/review actions, and context menus, while the header alone keeps the retained history's PR, CI, and review models live for its picker. The same contribution adds an issue button (order 2, so it follows the pull request button) for the GitHub issues the session's user messages referenced (gated by the per-view `SessionHasIssuesContext` key, registered from `contrib/github/browser/issueActions.ts`): a single issue renders as `#` and hovers to the issue title/description, while several render as ` issues` and open a sticky picker listing each issue on click; the leading icon reflects the aggregate live issue state (open green, closed-as-completed purple, closed as not planned/duplicate muted). Pull request and issue activations use the GitHub Pull Requests extension URI handlers when the extension is available, falling back to opening the GitHub URL externally. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. The **Chats** (Conversations) menu is rendered in the meta row at the end of the pills (`Menus.SessionHeaderMeta`, order 100) — it appears once the session has more than one **committed (non-draft)** chat, or when the active chat has subagents. -- A **chat groups grid** below the header ([browser/parts/chatGroupsView.ts](src/vs/sessions/browser/parts/chatGroupsView.ts)) — a `SerializableGrid` of one or more **chat groups** that partition the session's open chats (see §4.1.1). It fills the full session width. -- A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. - -The header is centered and capped to 990px via its own CSS class (`.chat-composite-bar.session-header-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)); `SessionView` measures the header's reported height and lays the chat groups grid out below it. The chat groups grid is laid out at full session width so each group's scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. The scroll-to-bottom button follows the trailing edge of this centered content column rather than the full-width viewport edge. Each constrained message row is also the positioning context for request overlays such as steering-message actions, keeping those controls anchored to the message instead of the full-width scroll viewport. - -Session metadata defaults to a second header row containing workspace, aggregate changes, pull requests, issues, and Chats. When `chat.agentSessions.showSessionMetadataInInput` is enabled, that row is removed: aggregate changes, pull requests, and issues join the horizontally scrollable pill row above the input; Chats moves into the title toolbar with its existing visibility rules; and read-only workspace metadata appears inline after the session title. Last-turn status pills remain available after the turn completes in this placement. The artifacts pill merges the artifacts the agent recorded with the previewable files the session wrote outside its workspace, de-duplicated by resource with the agent's entries winning; a single artifact opens directly, while several collapse into an `N Artifacts` pill whose dropdown groups them by type. Right-clicking the row — on a pill or the empty space beside it — offers the pill visibility menu: `Hide ` for the pill under the cursor, then the kinds the session has data for, then the kinds it does not, separated into those three groups. Changes is never listed because it always shows once it has data. Customizations and Subagents start hidden and are turned on from this menu; choices persist across windows. The pills opt into `allowContextMenu` so the toolbar does not swallow the right-click per item. The customizations pill is chat-scoped rather than session-scoped and always summarizes — one customization still reads `1 Customization` — with a dropdown grouped by customization type that reveals the picked entry in the customizations editor. The shared `ChatPillsWidget` lives in the workbench layer and consumes observable pill descriptors; the artifacts and customizations pills share one `ChatSectionPillActionViewItem` configured by presentation options. Sessions owns the adapters from session state and menus so the workbench layer never imports Sessions. - -**Composer clipping.** Monaco measures its host from `clientWidth`, which includes padding. The new-session editor therefore expresses its horizontal inset with margin so its scrollable element remains inside the clipped input surface; the running-session editor's rounded working-state clip extends through the input's trailing padding so the full scrollbar remains visible. - -**Pitfall:** absolute request overlays must not remain positioned against the full-width `.interactive-session` after message rows are independently constrained. Make the constrained row their positioning context or hover actions drift into the viewport gutter. Request rows must also override the tree's `.monaco-tl-contents { overflow: hidden; }`, otherwise controls positioned above the request are clipped at the row boundary. - -**Pitfall:** don't cap the chat viewport width in `SessionView` layout when you need edge-aligned scrollbars. Keep the viewport full-width and center only the inner chat content so alignment and scroll ergonomics both hold. - -**Pitfall:** Agents chat styles shared by session views and editor-hosted chats must provide a fallback when they reference SessionView-scoped CSS variables. Editor-hosted chats live outside the SessionView subtree, so an unresolved variable invalidates the entire declaration. - -**Pitfall:** Changed-file rows must share left-aligned insertion and deletion column widths derived from the current list, with tabular numerals, so every `+` and `-` starts on the same vertical line. Apply the measured widths directly to rendered count spans rather than introducing unregistered CSS variables; keep the labels on the compact edit-session type role and spacing, since bold weights or per-value padding make the row stats too heavy. - -**Pitfall:** a meta-row action view item that renders a `Button` (`.monaco-text-button`) cannot color a codicon glyph via a normal inline `style.color`, because `button.css` forces `.monaco-text-button .codicon { color: inherit !important }`. To give a meta icon its own theme color (e.g. the PR state color), set the color inline **with `!important` priority** (`el.style.setProperty('color', value, 'important')`) — an inline `!important` declaration wins over an external author `!important` rule in the cascade. - -**Pitfall:** combined codicon glyphs (e.g. `git-pull-request-done`) have a wider horizontal advance (~16px) than `*-compact` glyphs (e.g. `worktree-compact`, 12px), so even at `font-size: 12px` their layout box stays wide and pushes the following label away. Setting `font-size` alone does not fix it — clamp the icon box with explicit `width`/`height` set to `--vscode-codiconFontSize-compact` plus `justify-content: center` so the extra advance overflows harmlessly and the label sits tight against the glyph. - -**Pitfall:** don't put `overflow: hidden` on the meta row. The meta buttons are secondary `Button`s whose focus ring is drawn with `outline-offset: 2px`, so it extends a few pixels outside the button. When the meta row's height equals the button height (22px) and the row clips its overflow, the ring is sheared flat at the top and bottom. Leave the row `overflow: visible` and rely on the header's `padding-bottom` and the title-row gap above to give the ring room. - -#### 4.1.1 Chat groups grid - -Within a session view, chats default to a single **chat group** rendered as a tab strip. The user can drag a chat tab to the side or bottom edge of a group to split it into a new group, arranging the session's chats in a grid — mirroring VS Code editor groups. - -- `ChatGroupsView` ([browser/parts/chatGroupsView.ts](src/vs/sessions/browser/parts/chatGroupsView.ts)) owns the `SerializableGrid` plus the **group/chat assignment model**. The session's **visible chat tabs** (`IActiveSession.visibleChatTabs` — its open chats minus hidden subagent chats) are the source of truth for which chats the grid partitions; the grid is a UI-only partition over those chats. A reconcile autorun maps `session.visibleChatTabs` / `session.activeChat` into the groups: it prunes stale assignments, assigns newly added chats to the active group, removes empty groups (keeping at least one), and reflects a changed session-active chat onto its owning group. Focusing a group activates that group's chat through `ISessionsService`, keeping chat-scoped commands and context keys aligned without letting unrelated catalog updates steal the focused group. -- **Persistence.** When a created session holds more than one group, its partition (each group's ordered chat resources + active chat, the grid tree, sizes, and active group) is persisted to **workspace storage** keyed by `session.sessionId` (a single `sessions.chatGroupsLayout` map). New-session drafts always use one fresh group and clear stale state for their ID. The layout is captured on mutations (split / move / active-group change / reconcile) and re-captured on session switch-away and dispose (to snapshot the latest sash sizes). On reopen, `_tryRestoreLayout` deserializes the grid (each leaf's `index` maps a node back to groups) instead of building a single group. Because a session's chat catalog loads asynchronously after reload, restore keeps a saved `resource → group` assignment alive and routes each chat (including late-loading ones) back to its saved group via the reconcile autorun. Restoration completes when all saved chats are present, the catalog changes from its initial snapshot, or `session.loading` reports that initialization has settled. Missing chats are then treated as deleted and empty groups collapse. Restore is fully observable-driven (no timeouts). A single-group session stores nothing and clears any prior entry. -- `ChatGroupView` ([browser/parts/chatGroupView.ts](src/vs/sessions/browser/parts/chatGroupView.ts)) is a single grid leaf hosting a `ChatCompositeBar` (its group's tab strip) above a kind-switched chat view (see the table below). Each group independently renders its own active chat, so multiple chats can be visible side-by-side. When the group's active chat is **read-only** (non-interactive — e.g. a subagent transcript or an archived session), a `SessionReadOnlyBanner` ([browser/parts/sessionReadOnlyBanner.ts](src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts)) is shown flush below the tab strip in place of the composer, with an inline **Restore** action for archived sessions. The banner is aligned with the tab strip: with a lone group it is capped to the centered content band and centered (via a `.single-group` CSS rule in [chatGroupsView.css](src/vs/sessions/browser/parts/media/chatGroupsView.css), mirroring the tab strip's rule); with more than one group it spans the full leaf width. Its `toJSON` carries the group's serialization `index` so the grid deserializer can map restored nodes back to groups. -- `ChatCompositeBar` ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) is a tab-strip renderer driven by an `IChatCompositeBarDelegate` supplied by the owning group. Its tabs are draggable and render this group's `visibleChatTabs` in the group's order; a read-only chat's tab shows a **lock** icon. The tab strip is shown (via the group's `tabsVisible` observable) when more than one group exists, or — for a lone group — when `IActiveSession.shouldShowChatTabs` is set (the session has more than one visible chat tab). At the end of the strip a trailing **New Chat** button (gated on `ISessionCapabilities.supportsMultipleChats`, disabled for archived sessions) is pinned; New Chat routes back through the delegate so the new chat opens into the clicked group. The **Conversations** menu is not on the tab strip — it lives in the session header meta row (see §4.1). Each non-main tab renders its close button from the contributed per-tab `Menus.SessionChatTab` (context = `{ session, chat }`), whose `sessions.chatCompositeBar.closeChat` command hides the chat session-wide (reopenable from Conversations); the tab context menu offers **Rename** / **Delete Chat** gated on `getChatCapabilities`. A tab is also a drag source for a `#chat` reference (via `fillChatReferenceDragData`, resolving the chat's backend resource through `ISessionsProvidersService`) so it can be dropped into an agent-host chat input. Because the Agents workbench is always modern, the tab DOM consumes reusable editor-tab hooks from [workbench/contrib/modernUI/browser/media/tabs.css](src/vs/workbench/contrib/modernUI/browser/media/tabs.css), while [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css) retains only chat-specific layout and adornments. `SessionView` exposes the shared hook's focused/unfocused group state so side-by-side sessions follow the same color branches as editor groups, and `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) supplies the shared visual tokens. -- Drag-and-drop is handled by `ChatGroupDropTarget` ([browser/parts/chatGroupDropTarget.ts](src/vs/sessions/browser/parts/chatGroupDropTarget.ts)), which displays a 5-zone overlay (left / right / top / bottom / center) on the hovered group. Dropping a chat onto a group's **center** moves it into that group; dropping it onto an **edge** splits it into a new group in that direction. Subagent pills in the transcript use the same payload and drop zones; because subagents are hidden from the tab strip until opened, the drop first surfaces the subagent and then places it in the selected group or split. Alt+Enter on a focused subagent pill provides the keyboard-equivalent open-to-side action. The dragged chat's `{ sessionId, resource }` is carried on the drag event's **`dataTransfer`** (mime `SessionsDataTransfers.CHAT`, via `fillSessionChatDragData`/`isSessionChatDrag`/`getSessionChatDragData` in [browser/dnd.ts](src/vs/sessions/browser/dnd.ts)); drops from a different session are ignored. **Pitfall:** the group-move payload must **not** use the shared `LocalSelectionTransfer` singleton, because a chat-tab drag also offers a chat-*reference* payload (`DraggedChatReferenceIdentifier`, dropped into a chat input) that uses that same singleton — and `LocalSelectionTransfer` is a single global slot, so whichever payload is set last wins. Reference-carrying tabs (agent-host chats) would otherwise clobber the group-move identifier, so the drop target's `dragenter` saw no chat drag and never showed the split zones. The `dataTransfer` keeps the two payloads independent: its `types` are readable during `dragover` (to gate the overlay) and its value on `drop`. -- Keyboard users can focus the previous/next chat group, split the active chat right/down, and move it to the previous/next group through the corresponding Sessions commands. In multi-group layouts, each group and tab list announces its one-based position and total count. -- A newly opened, unassigned chat whose `origin.parentChat` is visible uses an existing group adjacent to that parent when one is available, regardless of whether it was opened from the transcript, Chats menu, or another surface. Existing and manually moved assignments remain authoritative. Without an adjacent group it opens normally; explicit open-to-side creates a new group. -- **Width.** With a lone group the session reads like the classic centered chat: the header, the group's tab strip, and the inner chat content (message/input cards) all align to the centered 950px band. Once the session holds **more than one group**, everything spans full width — `ChatGroupsView` drops the `.single-group` class so the centered cap on the chat content and tab strips is removed (`max-width: none` in [chatGroupsView.css](src/vs/sessions/browser/parts/media/chatGroupsView.css)), and `SessionView` adds a `.grid-layout` class and lays the header band out at full width too (driven by an autorun on `ChatGroupsView.groupCount`). -- **Active group.** All chat groups within a session share the same background — they inherit the session-level `--session-view-background` (active when the session is active, dimmed together when the session is inactive). Individual groups are **not** dimmed relative to one another: active/inactive coloring distinguishes whole sessions, not groups within one session. `ChatGroupView` still tracks the focused group (the `.active-group` class via `setGroupActive`) as a state hook, but it carries no background/accent styling. - -The chat view inside a chat group is one of three kinds (`ChatViewKind` in [browser/parts/chatView.ts](src/vs/sessions/browser/parts/chatView.ts)), selected per autorun based on the group's active chat: - -| Kind | Used when | Concrete view | -|------|-----------|---------------| -| `'newSession'` | The bound session has not been created yet | `NewChatView` (workspace / session-type picker + input) | -| `'newChatInSession'` | The session exists but the group's active chat has `SessionStatus.Untitled` and is fully interactive (or the group has no chat yet) | `NewChatView` (variant for new chat in an existing session) | -| `'chat'` | The session and the group's active chat are both created | `ChatView` (renders the group's active chat) | - -Concrete implementations live under `contrib/chat/` and are obtained via `IChatViewFactory` so the `browser/` layer doesn't have to import contrib code. - -The `NewChatView` input uses the control-tier corner radius for its send button, so the primary action is a rounded square in both desktop and phone layouts rather than a circular control. The focus outline follows the same control-tier shape. The input toolbar owns the spacing between adjacent actions through a shared flex gap rather than button-specific margins. - -`ChatView` mounts session input banners directly above the chat input. Fix Checks and Address Comments wait for that session's chat model before running; while waiting, the primary action is disabled and a border progress indicator appears after one second. Reveal on the CI failures banner opens the session's pull request through the shared Open Pull Request action. The standard comments banner follows the primary button accent, while the CI failures banner uses its orange warning accent for the card, primary action, and progress border. - -The shared chat input can show a transparent VS Code pet overlay above the composer. `/vscode-pet` toggles the persisted preference in active chats and the new-session composer. The state hooks for idle, sleeping, processing, confirmation, completion, and activation remain wired, but currently every state shows the same idle buddy: blue in Stable and green in Insiders/development builds. Active chats anchor the pet to the actual input row so confirmation and question widgets above it do not add spacing, while the new-session composer anchors it to its input-area wrapper. Cursor-tracked pupils render over eye-less derivative sprites so movement cannot expose the original baked-in eyes; the source PNG and GIF assets remain unchanged. Enabling makes the pet hop into place; disabling makes it duck away before its image source is unloaded. Both transitions are interruptible and skipped when reduced motion is enabled. Hovering the pet invites the user to show it some love and teases future interactions. - -When a `ChatView` loads its chat model (`acquireOrLoadSession`), it surfaces progress on **its own** progress bar, pinned to the top of that grid leaf. This mirrors how each editor group owns its `ProgressBar` (see `EditorGroupView`): the bar is created by the leaf host `AbstractChatView`, wrapped in a `ScopedProgressIndicator` (reused from `vs/workbench`) with an always-active scope, and driven via `AbstractChatView.showProgressWhile(promise, delay)`. Concurrent loads in other visible sessions each show their own progress instead of competing for a single part-wide bar, and overlapping loads on the same leaf are joined by the indicator so the bar only hides once all have settled. A short delay avoids flashing the bar for fast (cached) loads. - -### 4.2 Visibility Model - -The set of session views in the part is driven by `ISessionsService.visibleSessions` (services — see [services/sessions/browser/sessionsService.ts](src/vs/sessions/services/sessions/browser/sessionsService.ts)), which is backed by the `VisibleSessions` model helper (see [services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)). - -Key invariants: - -- **Multiple visible sessions, one active.** The Sessions Part may show one or several session views side-by-side. Exactly one of them is the **active** session at any time — the one that receives keyboard focus, drives context keys, and is reflected in the titlebar / sidebar / auxiliary bar. -- **Active session is observable.** Visible and active sessions are exposed as `IObservable` and `IObservable` respectively. `SessionsService` (services) owns the single reconcile autorun: it subscribes once and calls `SessionsPartService.updateVisibleSessions(visible, active)`, which forwards to `SessionsPart`. The part is a **passive renderer** — it injects neither the model nor the view. -- **One slot may be the "empty" slot.** A visible session of `undefined` represents a not-yet-created chat — its session view renders the `'newSession'` chat view (workspace picker + input). The workspace and harness pickers are capped at 400px and 200px, respectively, so long labels truncate without crowding out the other controls. At most **one** slot may be `undefined` at any time. When the user submits its first message, the placeholder transitions into a real session and the grid slot is preserved. -- **Sticky vs non-sticky.** The visibility model marks each slot as sticky (user-pinned) or non-sticky. Non-sticky slots are recycled when a new session opens; sticky slots are preserved. The empty slot is always non-sticky. This lets the user pin a session to keep it visible while still flowing through other sessions in the remaining slots. -- **Slot reuse on reconcile.** `SessionsPart.updateVisibleSessions` grows or shrinks its internal pool of `SessionView`s to match the visible count, then rebinds each surviving slot to its session by position via `SessionView.openSession(session)`. Slots are never destroyed and recreated for an existing session — only added at the right or popped from the right when the count changes. -- **Focus promotes to active and expands a minimized session.** Focus-in or pointer-down on a non-placeholder session view promotes that session to active (via `SessionsPartService.onDidFocusSession` → `ISessionsService.setActive`, which updates the active visible slot — and hence `ISessionsService.activeSession`). If the activated view is at its minimum width, the internal grid expands it and collapses its sibling views to their minimum widths, matching minimized editor-group activation for both pointer and keyboard focus. -- **Maximize.** When two or more non-placeholder views are visible, the active view can be maximized within the part's internal grid; the part exposes `toggleMaximizeSession(sessionId)`. -- **Restored on reload.** The visibility model is persisted to workspace storage (order, sticky state, and which slot is active, including the empty new-session slot). On startup `ISessionsService.restoreVisibleSessions()` rebuilds the grid, waiting for each session's provider to make it available and re-applying order, sticky flags, and the active session. To avoid flicker, restore waits for the active session, then lays out all sessions that are already available in one atomic transaction (`VisibleSessions.restoreGrid`) rather than showing the active session alone and reflowing as siblings load. Sessions whose provider surfaces them later are inserted into their persisted position incrementally. Once the grid has been laid out, keyboard focus is moved into the restored active session (matching the behaviour when a session is opened explicitly) so the user can start typing immediately. Focus is driven by `ISessionsService` observing its own `activeSession` (the active visible slot) rather than any model service calling into the view. The move is guarded so it never steals focus from another surface: focus is pulled into a session only when it currently rests on ``/nothing (startup restore) or already within the grid (moving between leaves), so an incidental active-session change (e.g. the fallback after deleting a session from the list) does not yank focus out of the list. Deliberate opens originating elsewhere move focus via their own explicit `focusSession` call. Restore must win the race against the empty new-session slot, whose workspace picker resolves asynchronously on the same provider-registration event restore waits for and would otherwise create and activate an untitled draft. Three mechanisms guarantee restore wins: (1) `ISessionsService` and `ISessionsManagementService` are both registered **eagerly** so the restore wiring and visibility model are alive before the first paint; (2) when restore rebinds the placeholder slot to the restored session, the new-session view (and its `NewChatWidget`) is disposed, and `NewChatWidget` guards its async workspace-selection handler with `this._store.isDisposed` so a late-resolving picker cannot create a draft for a slot that has already been claimed by a restored session; (3) untitled drafts are never persisted — `restoreVisibleSessions` drops them from the snapshot (`_snapshotVisibleSessionStates`) — so a stale draft can never be restored. The restoring state is intentionally not a UI suppression flag. (Restore itself drives no part-wide progress; once a session's leaf is laid out, that leaf shows its own load progress as described above.) - -### 4.3 Mobile / Phone - -On phone-class viewports the Sessions Part is replaced by `MobileSessionsPart` (chosen at construction time by `SessionsPartService`). It enforces a single visible session — never a side-by-side layout — and otherwise reuses the same `SessionView` host. - ---- - -## 5. Editor Presentation - -The Agents window defaults `workbench.editor.useModal` to `some`. Editors that require a modal, such as Settings and Keyboard Shortcuts, open in `ModalEditorPart`; ordinary editors open in the main editor part. - -| Trigger | Behavior | -|---------|----------| -| Ordinary editor opens (no explicit group) | Opens in the main editor part | -| Editor requiring a modal opens | Opens in modal overlay | -| All editors closed / Escape / backdrop click | Modal closes and is disposed | - -When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Details** action for the auxiliary bar (labelled "Toggle Secondary Side Bar" in the non-single-pane layout), and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). In the Agents-window tab strip, the editor-actions side first shrinks down to 50px before the tab scroller starts shrinking. When tab actions are placed on the left, tabs retain trailing spacing consistent with the modern editor tab style. - -The Agents workbench opts into the shared tab presentation through the tab-specific `modern-ui-tabs` root class and imports `workbench/contrib/modernUI/browser/media/tabs.css` and `workbench/services/themes/browser/modernTabColorCustomizations.ts` directly from its editor contribution. It does not apply the broad `modern-ui` class, because that class also changes workbench-wide part metrics and requires the complete Modern UI module set. Editor tab DOM, interaction behavior, presentation, and legacy color-customization fallbacks therefore stay aligned with the standard VS Code editor window; Sessions owns only the actions contributed to the shared tab strip. Do not copy shared editor-tab rules into a Sessions stylesheet: duplicated presentation immediately drifts when the common editor tab design changes. The shared add-tab host stretches across the tab row's actual hit-target height and remains sticky at the trailing edge so its icon stays aligned and available while tabs scroll. - -Agents-only editor-type exclusions are configuration defaults: `workbench.editor.hiddenEditorTypes` defaults to hiding Markdown Preview in the Agents window. Keep these exclusions at the picker boundary rather than threading Agents-specific editor ids through editor-group APIs; normal editor windows and editor resolution remain unchanged. - -When the auxiliary bar is hidden the editor becomes the rightmost card and expands into the freed space; the workbench's 10px right gutter still applies, and a `.noauxiliarybar` rule in `browser/media/style.css` restores the editor's right border and right corner radii so it keeps its card appearance. - -The single-pane editor group renders its title actions from sessions-owned menus, which shadow the core `MenuId.EditorTitle`. So `editor/title` items contributed by **extensions** would otherwise be dropped. `EditorTitleMenuBridgeContribution` in `contrib/editor/browser/editor.contribution.ts` (active only when `isSinglePaneLayoutEnabled`) bridges them: it listens to `MenuRegistry.onDidChangeMenu(MenuId.EditorTitle)` and mirrors **only** the extension-contributed items into the right-side `Menus.SessionsEditorHeaderSecondary` menu. Extension `navigation` items map to the inline `extension/navigation` group; every other extension group maps to `secondary/extension/` so it remains in `...` with its relative grouping preserved. Header actions receive the active editor's original URI as their forwarded argument, matching standard editor-title invocation. Extension items are identified two ways: command items by `item.command.source` (set by the `commands` extension point in `menusExtensionPoint.ts`), and submenu items by their `api:`-prefixed `submenu.id` (extension submenus are registered as `MenuId.for('api:')` by the `submenus` extension point). Core items have neither and are not bridged (they are already dual-contributed where needed). The mirror is kept in sync (a `DisposableStore` is cleared and rebuilt on every menu change) so it tracks extensions registering/unregistering. - - -The Toggle Details action (Toggle Secondary Side Bar in the non-single-pane layout) collapses or restores the secondary side bar while the editor stays open. In the single-pane layout it also has a default keybinding (**`⌥⌘L`**), and maximize/restore of the editor area has a default toggle keybinding (**`⌥⌘E`**, active only while the editor area is visible); both are scoped to the main sessions window with the single-pane setting enabled. The shared **Toggle Secondary Side Bar Visibility** command (`workbench.action.toggleAuxiliaryBar`) calls the layout service's `toggleSecondarySideBar()` operation. Its checked state uses the layout service's `isSecondarySideBarVisible()` context key, which is the auxiliary bar in classic layouts and the whole docked side pane in single-pane. Classic layouts toggle and announce the auxiliary bar. In single-pane, where the auxiliary bar is docked inside the editor, `toggleSecondarySideBar()` delegates to `toggleSidePane()`, which toggles the whole docked side pane and moves focus to the sessions list after hiding a focused side pane. If the editor was maximized, the toggle exits maximized mode before collapsing and restores maximization after the complete side-pane composition is shown again. The command therefore has consistent command-palette, keybinding, and focus behavior without inspecting a concrete layout. When a session's editor working set is restored on session switch, the editor part is revealed programmatically and the session's saved auxiliary bar visibility is honored (a side bar the user hid for a session stays hidden when returning to it). - -The main editor part can be explicitly revealed for workflows that target it directly. - -### Single-pane redesign (experimental — `sessions.layout.singlePaneDetailPanel`, default ON) - -> See [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) for the full scenario/state/transition catalog and the manual validation checklist. +The workbench omits the standard Activity Bar, Status Bar, and Banner. Part +positions are fixed by the Agents Window rather than user settings. -The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **on** (default), a non-phone Agents window uses a **single pane with one full-width editor title region**. Phone-class viewports always use the classic layout, regardless of the setting. When the setting is **off**, every Agents window also renders the classic layout documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). The single-pane layout supports `workbench.editor.showTabs` values `multiple` and `single`; while the unsupported `none` value is configured, the Agents editor part conditionally enforces `single`. When only the docked Auxiliary Bar is visible and the editor area is hidden, it enforces `multiple` so every managed detail tab remains directly available. +| Part | Ownership | +|------|-----------| +| Title bar | Window navigation and window-scoped actions | +| Sidebar | Sessions list and Sessions-owned sidebar views | +| Sessions Part | One or more visible session surfaces | +| Editor | File, browser, diff, and other editor inputs | +| Auxiliary Bar | Session details such as changes and files | +| Panel | Terminal and other panel views | +| Custom View Grid | Full-surface contributed views that replace session content | -- The auxiliary bar is removed from the workbench grid and **docked inside the editor part** (absolutely positioned on the right, below the editor tab strip); the grid's top-right row becomes `Sessions | Editor`, and the editor part spans the editor + detail-panel width. -- The editor group's **title region and header-hosted breadcrumbs span the full width**, while the editor content is inset on the right by the detail-panel width via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). The detail panel is always docked on the right, so no left margin is needed. -- A **full-width header** sits below the editor title row, spanning the editor content and docked detail panel. In `multiple` mode the title row is the tab strip; in `single` mode it is the active-editor name, followed by editor actions, the Add Tab toolbar, and layout actions. The toolbar immediately before layout actions owns the separator: Add Tab in single-tab mode, editor actions in multi-tab mode. Multi-tab Add Tab remains unseparated, and neither Sessions tab mode uses the standalone title-row divider. The Add Tab menu remains visible in dock-only mode; it is scoped to the main Sessions editor group, not `MainEditorAreaVisibleContext`. It keeps every supported editor type visible in `single` mode even when that editor is already open, because hidden tabs otherwise provide no discoverable inventory; `multiple` and dock-only modes continue to show only missing managed tabs. The single-title text uses the same leading content inset as the header, and the header itself owns the bottom separator so the stroke spans the docked detail width. `SinglePaneMainEditorPart.getGroupViewOptions()` enables the header with `showHeader` and supplies `Menus.SessionsEditorHeaderPrimary`, `Menus.SessionsEditorHeaderSecondary`, and `Menus.SessionsEditorHeaderLayout`. Whenever `showHeader` is enabled, breadcrumbs belong to `EditorHeaderControl` in that second row for every tab mode; the single-title control neither creates a competing inline breadcrumb nor repeats the path through its description. `EditorHeaderControl` owns the header DOM, evaluates those menus, renders their toolbars, and exposes its fixed visible height to `EditorTitleControl`; the title control includes that height in its layout. The header directly contains breadcrumbs followed by one actions container. That actions container owns the primary and secondary action hosts, followed by the layout-action host for **Toggle Details**. When the layout toolbar has visible items, the secondary toolbar uses the standard trailing-separator action to divide its actions (including `...`) from those layout actions; layout menu changes rebuild the paired toolbars so an empty layout toolbar never leaves an orphan separator. The layout host supplies the same far-side action gap that an internal separator receives from a single toolbar. The header must not create or style a separate separator element. Menu items own their active-editor `when` clauses. `SessionChangesEditor.scopedInstantiationService` only supplies its editor-scoped context for evaluating those clauses; its presence does not control whether the header is created. -- Text-file breadcrumbs reuse that **same fixed-height header row**. When `IEditorGroupViewOptions.showHeader` is enabled, `EditorTitleControl` creates `BreadcrumbsControl` directly in the header; otherwise it keeps the standard below-tabs placement in the title container. Header padding defines the shared left anchor for breadcrumbs and primary actions, so either starts at the same inset when the other is absent; the trailing edge uses a compact 4px inset for layout actions. Header-hosted breadcrumbs lay out at their actual flexed width, accounting for the header padding and sibling actions instead of using the full editor-group width. While the editor area is visible, the empty Files placeholder exposes the active session's first mounted working directory as its resource, so the row shows that Files view root; the breadcrumb model retains an exact workspace-root resource even when ordinary single-root file breadcrumbs omit that root. Detail-only layouts keep the breadcrumb hidden. This is a single-root fallback: multi-root sessions should eventually show a workspace-level breadcrumb that identifies the workspace and exposes all roots instead of presenting the first folder as the whole workspace. Editors without breadcrumbs or applicable menu actions hide the row and report zero header height. -- A vertical **sash** on the left edge of the docked panel resizes it (`DockedAuxiliaryBarController` in `browser/dockedAuxiliaryBarController.ts` owns `layout()` / `_ensureSash()`, created/driven by `SinglePaneMainEditorPart`). The preferred first-open width is 300px; explicit user resizes persist via the part-sizes snapshot. While the panel is visible it clamps to `[220px, editorWidth - 300px]`; dragging the raw sash width down to ~0 hides the docked detail panel, leaving the editor content visible. Temporary width growth from collapsing the sessions list is restored before persistence and must not become the user's detail width. -- While Editor is visible, **Toggle Details takes the full Details width from Sessions**: showing Details grows the editor grid node by the current Details width, and hiding Details returns that width to Sessions. Grid minimum widths may constrain how much Sessions can yield. -- During session-layout restoration, showing Details targets the persisted Editor-content width plus the Details width instead of adding Details to the current grid-node width, which may already include it. This keeps the side-pane split stable across repeated window reloads. -- Collapsing the sessions list transfers the freed sidebar width to the editor grid node when the editor content is **visible**, and to the **detail panel** (`_dockedAuxiliaryBarWidth`, with the editor node kept equal to it) when the editor content is **hidden** (detail-only). Reopening the sessions list restores the pre-collapse editor-node width / detail width. Keeping the hidden-editor node equal to the detail width ensures the width-based reveal-sync never mistakes a wide detail-only node for a revealed editor. -- When the editor part is hidden while the docked detail panel remains visible, the editor grid node stays visible for the shared tab strip but shrinks to the persisted detail-panel width, letting the Sessions part absorb the freed editor-content space. The detail panel fills that narrowed node below the tab strip and the editor content area collapses to zero. Its sash remains available so dragging the raw requested detail width below its 220px minimum hides the detail panel; the clamped visible width must not decide this. When a visible editor and its details no longer fit within the node, resize handling hides the details first and leaves editor content visible. -- A detail-only layout uses the exact docked detail-width model. The model starts at the comfortable 300px default only when no saved width exists; after the user drags the detail sash, hiding Editor, switching sessions, and reloading retain that exact width, including values below 300px. -- On reload, core editor restoration can emit `onWillOpenEditor` before the workbench reaches `Restored`. `SinglePaneWorkbench.revealEditorOnOpen` preserves a persisted hidden Editor during that phase, so an Aux-only pane paints directly without briefly revealing Editor; normal editor opens after restoration retain their usual reveal behavior. -- Side-pane visibility restoration does not depend on editor tabs being present. The controller does not hide a revealed Editor merely because the group is transiently empty; the persisted Editor/Aux composition renders first and managed tabs restore afterward. `SinglePaneWorkbench` is neutral when the group becomes empty; each lifecycle strategy owns its resulting visibility. -- Lifecycle rules that need both Editor and Details hidden call `hideSidePane()`, which idempotently delegates to the normal side-pane toggle lifecycle. The Existing Session strategy handles the last-editor removal before detail synchronization observes the empty group, so that lifecycle records the composition and hides Editor before Details under one suppression window. Its shared visibility profile ignores the toggle's intermediate part events and captures only the completed Editor/Details composition. Other settled empty Existing Session groups still hide Details. Managed-tab collapse ignores whole-side-pane toggles so Editor-first closure is not mistaken for a Detail-only collapse. Strategies hide a single part directly only when that other part must remain visible. -- Applying the Existing Session visibility profile restores both Editor and Details visibility. -- During reload there is a window after the workbench reaches `Restored` but before `restoreVisibleSessions()` supplies an active session. The New/Existing Session strategies (via `SinglePaneDetailPanelCoordinator`) return `Preserve` in that state; treating the missing session as `Hidden` would close persisted Aux, whose layout invariant reveals Editor, and paint Editor-only until the session profile arrives. -- Widening a detail-only editor node does not automatically reveal editor content. The editor area remains hidden until the user explicitly opens an editor workflow or toggles the editor area. This preserves the user's detail-only choice across sash drags and grid relayouts. -- When the outer editor sash makes a visible editor and its docked details too narrow to coexist, single-pane automatically hides details and leaves editor content visible. It captures the editor width after that hide and restores details only when the node can fit both the captured editor width and the detail width, so restoring details does not shrink the editor. This responsive detail behavior is exclusive to the single-pane layout. -- Revealing the side pane from *closed* (`setEditorHidden(false)`, e.g. the session-header Changes button opening the Changes editor) passes `Sizing.Distribute` to `SerializableGrid.setViewVisible`. The grid already knows the revealed view's location, so it distributes that containing split and Sessions and the side pane receive equal space without either part computing pixels, percentages, or a split reference. A genuinely user-chosen width still takes precedence. Double-clicking the outer Sessions/editor sash has a separate reset policy: with Details visible, it preserves the current Details width and splits all remaining width equally between Sessions and editor content, without a 600px cap. Grid minimum widths still take precedence in narrow layouts. Hiding Details after this reset restores an equal Sessions/Editor split, even when the reset itself did not visibly move the sash. With Details hidden the reset retains native equal distribution, and in detail-only mode it resets Details to 300px. -- Side-pane sizes and sash-reset intent are **workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes`), so switching between sessions keeps the same side-pane width and pending reset behavior — the layout controller does not track or restore either per session. The pending reset survives later sash resizes, is consumed when Details hides, and is cancelled by hiding the Editor. The workbench persists the docked side-pane geometry across reloads via `_savePartSizes` on `onWillSaveState`, restored by `createDesktopGridDescriptor`. Because the docked detail (auxiliary bar) lives **inside** the editor grid node, the persisted editor value is the pure editor-content width: `_persistedEditorWidth` subtracts the docked detail width **only when the detail is visible**, mirroring the descriptor, which adds it back only when the detail is visible. While Editor is hidden, `_savePartSizes` preserves the last valid editor-content width instead of accepting the cached 300px detail-only node as an Editor width. Subtracting the detail width unconditionally (the earlier bug) shrank an **Editor-only** session's side pane by the detail width on every reload, compounding toward zero. -- `_dockedEditorSizeBeforeHide` is captured whenever Editor hides while Details remains visible. The shared node must first shrink to the Details width, including layout-driven transitions into a New Session's Files-only composition. That combined width restores only when Editor reopens beside visible Details; an Editor-only composition restores the persisted pure Editor-content width and clears the composition-specific memento. Whole-side-pane closure still hides Editor before Details; the subsequent Details hide collapses the node without replacing the captured pre-collapse width. -- **Hide Editor** / **Show Editor** remain registered commands, and their `MenuId.EditorTitleLayout` contributions are retained with an always-false `when` clause so neither action renders in the single-pane UI. Their command implementations remain available: Hide reveals the auxiliary bar, hides the editor part, and restores the sessions list; Show reveals the editor area via `revealEditorPartExplicitly()` and focuses the editor group. The full-width editor header's trailing layout-action host continues to hold **Toggle Details** alone; Toggle Details is hidden for Browser, Pull Request, Issue, and Search tabs, which have no detail. Browser, Pull Request, and Issue editors hide the docked Details panel while their editor area is visible. Opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. -- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane). Each file header shows the live `+insertions -deletions` counts from the selected changeset alongside the file label. Clicking a Branch Changes file honors the same `sessions.changes.openSingleFileDiff` setting and Alt inversion as the standard layout, opening either a docked single-file diff or revealing the file in this multi-diff editor. When Changes details are visible, the full-width header exposes the List/Tree view-mode action for both the multi-file Changes editor and every docked single-file diff editor, including binary or custom-editor fallbacks; it shows the inline/side-by-side diff toggle only for the multi-file editor and text diff panes that support that layout. The auxiliary bar's composite tab strip + title are hidden, and the New/Existing Session strategies map the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser/Pull Request/Issue → hidden). -- **Run Code Review** renders as the first inline action on the right while the single-pane Changes editor area is visible. When the editor area is collapsed, it moves into the first group of the right-side `...` overflow, followed by a separator and the remaining overflow actions. -- Closing the last editor tab is lifecycle-owned: Existing Session hides both regions, New Session applies its Empty Files fallback, and Quick Chat leaves the side pane open. Opening any tab reveals the editor part again, and `DetailPanelController` restores the matching detail content for File/Changes tabs. -- **Editor-area tab collapse:** when the editor area is hidden (detail-only), the single-pane controller closes **every non-docked** editor tab (anything not `instanceof DockedEditorInput`) so only the docked Changes and Files tabs remain, capturing each closable one's untyped input **and tab index** (`editor.toUntyped()`); when the editor area is shown again the captured ones are reopened **at their original positions** (`SinglePaneDockedTabsCoordinator._collapseNonManagedTabs` / `_restoreCollapsedTabs`, constructed by `SinglePaneLayoutController._registerAuxiliaryControllers` and owned by `SinglePaneExistingSessionStrategy`). It is serialized on the shared docked-tab `Sequencer`, skipped during a layout-driven restore (`ISinglePaneLayoutContext.isRestoringSessionLayout`), and the capture is dropped on a session change. Non-restorable tabs (e.g. an **untitled Search editor**, whose `toUntyped()` returns `undefined`) are still closed but not restored; dirty editors are closed too (the workbench save/confirm flow applies), so they don't linger in a "closed" editor area. -- **Only Existing Sessions persist a shared Editor/Details visibility profile.** `SinglePaneExistingSessionStrategy` captures and reapplies both values via `SinglePaneVisibilityProfileStore`, including user sash/resize-driven Details changes; only strategy-owned transient Browser hides/restores are excluded. New Sessions and Quick Chats store no lifecycle visibility. Quick Chat hides the whole side pane once on entry; later explicit editor opens follow normal workbench behavior. Submit seeds the Existing profile. -- CSS is scoped by a `.dock-detail-panel` class on the workbench container; `:not(.dock-detail-panel)` reproduces the original grid-based styling. -- The docked auxiliary bar draws its own left and top borders with `--vscode-agentsPanel-border` so the detail panel reads as a bordered region connected to the middle divider. +The Sessions Part contains its own horizontal grid. Its leaves are not workbench +editor groups. ---- +## Grid behavior -## 6. Feature Support +The main workbench grid is non-proportional. The Sessions Part is the flexible +surface that absorbs container resize and part-visibility deltas. The Sidebar, +Editor, Auxiliary Bar, and Panel preserve user-established sizes within their +constraints. -| Feature | Supported | Notes | -|---------|-----------|-------| -| Sidebar / Aux Bar / Panel toggle | ✅ | Fixed positions (sidebar: left, panel: bottom) | -| Maximize Panel | ✅ | Excludes titlebar | -| Resize Parts | ✅ | Via grid sash or programmatic API | -| Zen Mode / Centered Layout / Menu Bar Toggle | ❌ No-op | — | -| Maximize Auxiliary Bar | ❌ No-op | — | +At most one high-priority surface is visible in the main horizontal chain: +normally the Sessions Part, or the Custom View Grid while a custom view is +active. This prevents fixed side parts from absorbing general window resize. ---- +The single-pane presentation may place the Auxiliary Bar inside the Editor's +grid node. Consumers must distinguish the actual Editor content area from the +shared grid node when interpreting visibility or size. -## 7. Parts Architecture +## Sessions Part -The Sidebar, Auxiliary Bar, and Panel extend `AbstractPaneCompositePart`; the Titlebar extends `Part` directly; the Sessions Part also extends `Part` (it is not a pane composite — it owns its own internal grid of session views, see [§4](#4-sessions-part)). All parts are instantiated eagerly so they register themselves with the workbench layout service before `createWorkbenchLayout()` builds the grid. The pane-composite parts are accessed through `AgenticPaneCompositePartService`, which replaces the standard `IPaneCompositePartService`. +Each visible session has one Sessions-owned view. The view presents the active +chat for that session and scopes commands, menus, and context keys to the +represented session. -Key differences from standard workbench parts: -- **No activity bar** — account widget lives in the sidebar footer -- **Fixed composite bar** — for pane-composite parts the position is always `Title`; the sidebar hides its composite bar (only the sessions list shows) -- **Card appearance** — Sessions Part, Auxiliary Bar, and Panel render as cards with rounded borders and margins; Sidebar is flush -- **Separate storage keys** — each part uses `workbench.agentsession.*` keys to avoid conflicts with regular workbench state -- **Sidebar footer** — a menu-driven toolbar below the sessions list, hosting the account widget -- **macOS traffic lights** — sidebar includes a spacer (70px) for window controls when using custom titlebar +`ISessionsService` owns: ---- +- visible-session identity and order; +- the active visible session; +- which chat is active in each session; +- restoration of the visible arrangement. -## 8. Contributions +The Sessions Part renders that model. It does not create a second active-session +store. -Contributions are registered via module imports in entry points (`sessions.common.main.ts`, `sessions.desktop.main.ts`). +Multiple visible sessions share the available Sessions Part width. Opening, +closing, and reordering views operate through `ISessionsService`. -Key UI surfaces: -- **Sessions View** — sidebar, shows sessions grouped by workspace with pinned section -- **Changes View** — auxiliary bar, shows file changes for the active session -- **Chat composite bar** — per-session peer-chat tabs; user-created side chats reuse this surface while tool-origin subagents stay hidden until opened -- **Chat / New Chat views** — hosted inside each `SessionView` in the Sessions Part, registered via `IChatViewFactory` from `contrib/chat/` +## Editor presentation -All session-window contributions use `WindowVisibility.Sessions` to only appear in the Agents Window. +The Agents Window supports two presentation families: ---- +The single-pane layout is the default on non-phone viewports when its startup +setting is enabled. Phone viewports always use the classic layout. The selection +is made during workbench creation and requires a reload when the setting changes. -## 9. Lifecycle +### Classic layout -1. `constructor()` → `startup()` → `initServices()` → `initLayout()` -2. `renderWorkbench()` — creates DOM and parts (editor part created hidden) -3. `createWorkbenchLayout()` — builds the workbench grid -4. `createWorkbenchManagement()` — eagerly creates the welcome/setup service. Wiring of the Sessions Part lives in `SessionsService` (an eager singleton): it owns the single reconcile autorun that reads `ISessionsService.visibleSessions` and calls `SessionsPartService.updateVisibleSessions(...)`, and it observes its own `activeSession` (the active visible slot) to move keyboard focus into that session's view via `SessionsPartService.focusSession` (guarded so it does not steal focus from a session the user is already interacting with). The part itself is a passive renderer; focus is a pure view concern — the management service never reaches into the part. -5. `layout()` → `restore()` — opens default view containers for visible parts +The Editor is a workbench-grid part and may be hidden independently of the +Sessions Part and Auxiliary Bar. Ordinary editors open in that main Editor; +editors that require modal presentation use `ModalEditorPart` without changing +the underlying workbench topology. -**Initial part visibility:** Sidebar ✅, Sessions Part ✅, Auxiliary Bar ✅, Editor ❌, Panel ❌. The editor pane comprises the editor and auxiliary-bar parts; the workbench adds `noeditorpane` only when both are hidden. In the single-pane layout, it instead reads the docked editor grid node's visibility, which is also visible for a detail-only pane. +### Single-pane detail layout ---- +The Editor and Auxiliary Bar compose one side pane next to the active session. +Editor tabs choose either editor content or a details view while the layout +coordinators preserve one coherent visibility model. -## 10. Per-Session Layout State +The durable state and transition catalog lives in +[SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md). Implementation behavior is +covered by the layout-controller and single-pane strategy tests. -The session layout controllers manage layout state as the user switches between sessions. All state is persisted to workspace storage so it survives restarts. This section is a summary — see **[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md)** for the full specification (switch trigger, multi-session handling, persistence, and invariants). +Editors must be opened through `IEditorService`. Sessions-specific presentation +must not bypass editor service behavior by opening directly on an editor group. -The implementation is split across three files in `contrib/layout/browser/`, each with a file-level spec of numbered rules (`B*`/`D*`/`M*`) that the code and tests reference by tag. Each concrete controller self-registers behind a platform guard: +## Custom views -- **`BaseLayoutController`** ([baseSessionLayoutController.ts](contrib/layout/browser/baseSessionLayoutController.ts), [spec](contrib/layout/browser/baseSessionLayoutController.md)) — abstract; shared panel / working-set / persistence / multi-session logic. -- **`LayoutController`** ([desktopSessionLayoutController.ts](contrib/layout/browser/desktopSessionLayoutController.ts), [spec](contrib/layout/browser/desktopSessionLayoutController.md)) — desktop and web desktop layout. Adds the auxiliary bar / view-state management described below (via the `_registerViewStateManagement()` hook). Imported from `sessions.desktop.main.ts` and `sessions.web.main.ts`. -- **`MobileLayoutController`** ([mobileSessionLayoutController.ts](contrib/layout/browser/mobileSessionLayoutController.ts), [spec](contrib/layout/browser/mobileSessionLayoutController.md)) — web phone layout (`isWeb && isMobile`). Keeps the shared logic but omits auxiliary bar management, which would cause disruptive auto-expand on narrow viewports. Imported from `sessions.web.main.ts`. +`ICustomViewService` owns the active contributed full-surface view. -### Auxiliary Bar +A custom view is mutually exclusive with the Sessions Part, grid Editor, +Auxiliary Bar, and Panel. The title bar and Sidebar remain available. Covered +parts retain desired visibility separately from effective grid visibility so +their state can be restored when the custom view closes. -Each session independently remembers whether the auxiliary bar is visible and which view container is active. When switching to a session, the saved state is restored. When switching away, the current state is captured. +Opening a session dismisses the active custom view. On phone layouts, custom +views participate in mobile navigation so platform back navigation dismisses +them. -**The side pane never opens automatically for existing sessions.** It is only shown when the user opens it; the controller never auto-reveals it on session switch or when a chat turn produces new file changes. A session with no explicit "visible" choice (including one that just converted from the new-session view to an existing session) keeps the side pane hidden until the user opens it. +## Part lifecycle -**New-session opening rules:** New Sessions do not own or persist side-pane visibility. When a New Session becomes active, if Editor is visible and the restored editor set contains only `EmptyFileEditorInput`, `SinglePaneNewSessionStrategy` hides Editor and leaves Auxiliary Bar visibility unchanged; this entry rule runs once and is cancelled by an explicit real-editor open. Separately, a completed closed-to-open **Toggle Side Panel** transition waits for managed-tab restoration; if it settles with exactly one Empty Files input, the strategy reveals Auxiliary Bar if needed and hides Editor, producing dock-only Files. Generic side-pane reveals do not drive this rule. Changes is unavailable until the session is created. +The workbench: -Every `SinglePaneNewSessionStrategy` event handler is gated on the current active session being an uncreated, workspace-backed New Session view (and on single-session display where applicable). Editor opens/closes, side-pane toggles, and detail changes while Existing Session, Quick Chat, multiple sessions, or no session is active must not mutate New Session pending state or layout. +1. creates the fixed grid and part instances; +2. restores persisted workbench part sizes and visibility; +3. starts the applicable layout controller; +4. reacts to visible-session, editor, and contributed-view state; +5. persists state through the owning services during shutdown. -**New-session close fallback:** `SinglePaneNewSessionStrategy` listens to `IEditorService.onDidCloseEditor`, then uses the shared `isMainPartEmpty(IEditorGroupsService)` predicate to identify the same all-main-groups-empty state as the workbench. It ignores closes while editor-part auto-visibility is suppressed, so layout-driven working-set/managed-tab cleanup cannot become a user-close transition even when it settles after the restore epoch. `SinglePaneWorkbench` is neutral for a genuine user close, so the strategy is the sole owner: if the closed input is not Empty Files, it uses the typed-input `IEditorService.openEditor(input, options, exactGroup)` overload to install one pinned/inactive Empty Files input, preserves Editor visibility, and opens the Files Details panel. Closing Empty Files itself closes the whole side pane. +Part instances and listeners are disposables. Repeatedly created per-session or +per-view state is owned by a scoped disposable store. -The Changes view's body is a vertical `SplitView` of File Changes, Other Files, and Checks. File Changes grows to its full rendered content height when possible, but never beyond it; its viewport budget reserves the Other Files header plus up to three file rows and the Checks header plus up to five check rows. Absent sections reserve no height and collapsed sections reserve only their header. Remaining height expands Other Files first and Checks second, up to each section's content height. Once the user moves a sash, manual pane sizes remain in effect until changing content constraints require adjustment; a temporarily empty pane relies on `SplitView`'s cached visible size when it reappears rather than reapplying its preferred default. Other Files and Checks remember their collapsed state independently per session for the lifetime of the window, including while their content is temporarily absent; draft-to-committed session replacement transfers that state. When File Changes has no changed files, it keeps a 140px minimum height for the empty state. +## Layout-controller boundary -**Editor maximized:** While the editor area is maximized (`IAgentWorkbenchLayoutService.isEditorMaximized()`), the Changes view is always shown in the auxiliary bar, **irrespective of the session's previous or saved state**. This is driven directly from the auxiliary-bar sync autorun, so it holds across session changes and changes-state updates while maximized. The forced visibility is never captured as the session's per-session preference, so when the editor is un-maximized the autorun re-runs and restores the session's real auxiliary bar state. +Layout controllers translate session activation into part capture and +restoration. They do not own session identity or the visible-session model. -`setEditorMaximized` (in `browser/workbench.ts`) treats maximize as a fully reversible state: on entering it snapshots the editor part's size and the surrounding parts' visibility, and on exiting it restores the auxiliary bar to its pre-maximize visibility and resizes the editor part back to its captured width. Without this, the auxiliary bar that the controller forces visible while maximized would otherwise remain (and shrink the editor) after un-maximizing, so the editor would not return to its previous size. +Classic desktop, mobile, and single-pane presentations intentionally use +different strategies where their compositions differ. Shared behavior belongs +in the base controller; presentation-specific behavior stays in the relevant +controller or strategy. -### Panel +See [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md) for rule tags, persistence, and +test ownership. -The panel (terminal / debug output) is hidden by default for all sessions. Each session independently tracks the user's last explicit show/hide action, and that state is restored on session switch. Its height remains workbench-level state rather than per-session state. In single-pane layout, revealing the Editor preserves the visible panel height; otherwise the grid can shrink the panel to its minimum while it redistributes space for the restored Editor node. +## Mobile boundary -### Editor Working Sets +Phone layouts replace selected parts and pickers with mobile subclasses while +preserving the same service and provider contracts. Mobile composition and +navigation are specified in [MOBILE.md](MOBILE.md). -Each session remembers which editors were open, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid editor part even when other editors are forced modal (`useModal: 'all'`), so their tabs still need per-session tracking. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. +## Contributions and loading -A session also remembers whether its editor part was hidden (e.g. the user closed the Side Panel while keeping editors open). Restoring such a session keeps the editor part hidden rather than forcing it back open with the working set. +Layout contributions register through the appropriate +`sessions.*.main.ts` entry point. Shared workbench code should change only when +the capability is useful outside the Agents Window; Sessions-specific policy +stays under `vs/sessions`. -This is coordinated carefully: the active session observable is updated before the workspace folders update, so `LayoutController` waits until the workspace folders reflect the new session before applying the working set (to avoid restoring editors into the wrong workspace). +## Change policy ---- +Update this specification only when part ownership, grid topology, presentation +families, or a cross-part invariant changes. Do not update it for: -## 11. CSS +- pixel values, styling, icons, or action placement; +- individual view or editor behavior; +- bug narratives and rejected implementations; +- per-session restoration scenarios already owned by controller rules and tests. -The workbench root element has class `agent-sessions-workbench`. Visibility classes (`nosidebar`, `noauxiliarybar`, `nosessionspart`, `nopanel`) are toggled on the main container. +## Related specifications -The shell background uses an accent-tinted radial gradient derived from `button.background`, with titlebar and sidebar wrappers transparent so the gradient reads continuously. High-contrast themes disable the gradient. +- [Documentation index](README.md) +- [Sessions architecture](SESSIONS.md) +- [Layout controllers](LAYOUT_CONTROLLER.md) +- [Single-pane scenarios](SINGLE_PANE_SCENARIOS.md) +- [Mobile layout](MOBILE.md) diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 3278400d9fb4ab..37986c64c65545 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -1,5 +1,9 @@ # Layout Controller — Per-Session Layout State +> **Specification change gate:** A bug fix that restores an existing rule belongs +> in a regression test, not this document. Update this specification only when +> the intended layout state machine or persistence contract changes. + This document specifies how the session layout controllers manage workbench layout as the user switches between sessions. The classic and mobile implementation is split across three files, each with its own file-level spec. Each spec states the behaviour as numbered **scenario rules** (and @@ -27,7 +31,8 @@ coordinators rather than being injected into editor-part construction; in particular, editor-part construction must not acquire `ISessionsService`, because the Sessions service graph already depends on editor parts. -It is the detailed companion to [LAYOUT.md §10 Per-Session Layout State](LAYOUT.md#10-per-session-layout-state). +It is the detailed companion to the +[layout-controller boundary](LAYOUT.md#layout-controller-boundary). --- @@ -80,7 +85,8 @@ cleared — they survive multi-session mode. Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-expand on narrow viewports. > **Docked detail panel (experimental).** With `sessions.layout.singlePaneDetailPanel` enabled, the auxiliary -> bar is docked inside the editor part rather than being a grid column (see [LAYOUT.md](LAYOUT.md) §5). +> bar is docked inside the editor part rather than being a grid column (see +> [Editor presentation](LAYOUT.md#editor-presentation)). > `SinglePaneExistingSessionStrategy` persists one shared Existing Session Editor/Details profile > (via `SinglePaneVisibilityProfileStore`) under `sessions.singlePane.sidePaneVisibility`. > New Sessions do not apply or capture an Editor @@ -346,44 +352,10 @@ does, causing the aux bar to fall back to the default-visible logic (§3.2) on t - **Observables, not events**, drive all session-switch logic. - **Multiple visible sessions** disable per-session view/panel sync and clear that state (working sets preserved). -- **In the classic layout, the side pane is never auto-opened for existing sessions on restore** — it opens automatically as - the new-session default (§3.2 step 2) and stays visible when an already-visible new session is - submitted (§3.3). A created session with no explicit "visible" choice stays closed until the user - opens it. -- **In single-pane, Existing Sessions restore a shared Editor/Details profile** while New Sessions apply their - entry rules. Quick Chat hides the whole side pane once on single-session entry; later explicit - editor opens follow normal workbench behavior. With multiple visible sessions, the focused session may reveal parts from its - matching profile, but it never automatically hides parts used by another session. -- Existing→Existing detail content selection ignores the outgoing active editor. It selects content - only when a concrete different incoming editor activates or the incoming session-layout restore ends, - avoiding a Files→Changes flash. -- Classic-layout D10 empty-Aux cleanup is disabled in single-pane mode; Quick Chat strategy is the - sole owner of Quick visibility and waits for entry editor restoration to settle before applying it. -- **In the classic desktop layout, the sessions sidebar is auto-managed on a small window ([D7])** — when the main container is - 1800px wide or narrower and both the editor and auxiliary bar are open, the sidebar is hidden; it is shown - again once either closes or the window widens, unless the user closed it themselves. Suspended while - multiple sessions are visible, and switching sessions never auto-hides the sidebar: the base-controller - restore epoch (`_withSessionLayoutRestore` / `_isRestoringSessionLayout`) wraps both the aux-bar restore - and the editor working-set apply (`_applyWorkingSet`), so the side-pane / editor reveals a switch causes - re-baseline the state instead of triggering an auto-hide. Gated by the - experimental setting `sessions.layout.autoCollapseSessionsSidebar` (default on in non-stable builds). See - [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) D7. -- **In single-pane, the Sessions sidebar is always explicit** — opening/closing Details or opening an - editor never hides or shows the sidebar. - Working-set save/apply waits for **workspace folders** to catch up with the active session. -- **An empty auxiliary bar is hidden (desktop, [D10])** — when the aux bar has no active view container - (e.g. a workspace-less quick chat where Changes/Files are gated off), the `AUXILIARYBAR_PART` is kept - hidden instead of showing an empty column, updating reactively as the active session flips — including - when the part itself becomes visible (a bare toggle / restore that shows the column before a container - opens), so the detail toggle never reads "on" over a blank panel. The empty-part hide runs under - `suppressEditorPartAutoVisibility()` so it never resurrects the editor as a side effect, and the docked - host never force-opens a `hideIfEmpty` container with no active views. The controller only hides an empty - aux bar (reveals stay with D3/D8), and **Toggle Side Panel** only reveals the part that has content — - never an empty aux bar, and is **disabled entirely for quick chats** - (`IsQuickChatSessionContext.negate()`). Invariant: `partVisibility.auxiliaryBar` - (⇒ `AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true iff the docked detail panel is rendered with - an active view container. -- **Single-pane new-session views hide only redundant Editor content (desktop, [D11])** — when an - uncreated workspace session is entered in single-pane mode and the restored editor set contains only - Empty Files, Editor is hidden once under `suppressEditorPartAutoVisibility()`. Auxiliary Bar visibility - is not changed or persisted by the New strategy, and later user editor reveals are respected. +- Classic desktop behavior is owned by rules `D1`-`D11` in + [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md). +- Mobile behavior is owned by rules `M1`-`M2` in + [mobileSessionLayoutController.md](contrib/layout/browser/mobileSessionLayoutController.md). +- Single-pane visibility and detail selection are owned by + [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) and the strategy tests. diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 03b2b875949d6e..4bb405e109fd68 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -1,195 +1,130 @@ -# Mobile Agent Sessions — Architecture +# Mobile Agents Window architecture -## Core Principle +> **Specification change gate:** Do not update this document for responsive bug +> fixes, control behavior, styling, or unfinished work. Update it only when +> mobile composition, navigation ownership, or the adaptation contract changes. -**Every feature accessible in the desktop window must be accessible on mobile — same functionality, different presentation.** Mobile is NOT "desktop minus stuff." It is a parallel UI layer where the same services, views, and actions are rendered through mobile-native interaction patterns. +## Scope -## Architecture +The phone layout adapts the Agents Window to a narrow, touch-first viewport +without creating a separate session, provider, or command model. -### Mobile Part Subclasses +This specification defines stable mobile composition and ownership. Exact +dimensions, touch targets, styling, picker contents, and individual feature +availability belong in code, design tokens, component fixtures, and tests. -Desktop Parts (`SessionsPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()`. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). +## Core principle -Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. +Mobile components are presentation adapters over shared Sessions services. +They may replace a desktop part, picker, or editor presentation, but must +preserve provider-neutral session identity, scoped context, and command +semantics. -This means: -- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTitlebarPart`, and CSS. -- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. +## Viewport classification -After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. +The Agents Window derives phone layout from the current viewport and platform +environment. Mobile context keys are declarative inputs for menus, view +registration, and presentation selection; they are not the source of truth for +model or provider behavior. -### View & Action Gating +Part factories select their mobile or desktop implementation once during +construction, based on the initial viewport. They do not replace part instances +when the viewport later crosses the phone breakpoint. -Views, menu items, and actions use `when` clauses with the `sessionsIsPhoneLayout` context key to control visibility in phone layout. This follows a **default-deny** approach for phone: +## Composition -- **Desktop-only features** add `when: IsPhoneLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on phone. -- **Phone-compatible features** (chat, sessions list) have no phone gate — they render on all viewports. -- **Phone-specific replacements** (when ready) register with `when: IsPhoneLayoutContext` and live in separate files under `parts/mobile/contributions/`. +Phone layouts prioritize one primary surface: -Tablet and larger viewports currently fall back to the desktop layout; no separate tablet design exists yet. +```text +Mobile title bar +Active session or custom view +Mobile navigation and transient overlays +``` -Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. +Desktop side parts do not remain as permanently visible columns. Their content +is presented through mobile navigation, drawers, sheets, or full-screen +overlays as appropriate. -#### Current Gating Status +The active session and chat remain owned by `ISessionsService`. Mobile +navigation must not create a second active-session store. -| Feature | Phone Status | Mechanism | -|---------|--------------|-----------| -| Sessions list (sidebar) | ✅ Compatible | No gate — session rows reuse the inline workspace badge under custom groups; live-status rows still hide details, and the phone toolbar reservation constrains the remaining badge width | -| Sessions Part (chat views) | ✅ Compatible | No gate — phone enforces a single visible session via `MobileSessionsPart` | -| Changes view (AuxiliaryBar) | ❌ Gated (with mobile equivalent) | `when: !sessionsIsPhoneLayout` on view descriptor; phone uses `MobileChangesView` overlay reachable from the title-bar Changes pill | -| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | -| Logs view (Panel) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | -| Terminal actions | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | -| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTitlebarPart replacement | +## Mobile part pattern -### Phone Layout +When a factory selects a mobile subclass, that instance remains alive for the +part's lifetime. It checks the current viewport and delegates to desktop +behavior after rotating or resizing out of phone layout. A mobile subclass: -On phone-sized viewports (`< 640px` width): +- reuses the shared service and contribution contract; +- changes only composition, interaction, or presentation; +- gates mobile behavior on the current viewport without recreating the part; +- preserves scoped session context for commands and menus. -``` -┌──────────────────────────────────┐ -│ [☰] Session Title [+|👤] │ ← MobileTitlebarPart (prepended before grid) -├──────────────────────────────────┤ -│ │ -│ Chat (edge-to-edge) │ ← Grid: SessionsPart fills 100% (single SessionView) -│ │ -│ │ -│ │ -│ ┌──────────────────────────┐ │ -│ │ Chat input │ │ ← Pinned to bottom -│ └──────────────────────────┘ │ -└──────────────────────────────────┘ -``` +Desktop-only behavior must be gated before presentation rather than hidden with +CSS after instantiation when the underlying component is unsuitable for phone +layout. + +## Navigation + +The workbench-owned `MobileNavigationStack` tracks nested mobile layers such as +drawers, custom views, pickers, and full-screen editors. Platform back +navigation dismisses the top layer before leaving the current session surface; +it does not control part-instance lifetime. + +Opening another session resets or replaces transient navigation layers through +the owning service. Components do not coordinate navigation by reading another +component's storage keys. + +## Pickers and actions + +Mobile pickers adapt the same underlying selection controllers used by desktop. +Provider selection, model selection, configuration, and workspace resolution +remain owned by their shared services. + +Actions use shared commands and menu IDs with mobile context-key gating. +Presentation-specific action view items may differ, but invoking an action must +resolve the same scoped session and operation. + +## Editors and changes + +Mobile file and diff review use phone-native editor presentations. The design +for mobile diff surfaces is documented in +[MOBILE_DIFF_EDITORS.md](browser/parts/mobile/contributions/MOBILE_DIFF_EDITORS.md). + +Editor inputs still open through `IEditorService`. Mobile overlays and +navigation wrappers must preserve editor lifecycle and disposal behavior. + +## Custom views + +Custom views use the same `ICustomViewService` state as desktop. Phone +presentation pushes the custom view onto mobile navigation and dismisses it +through the normal back-navigation path. + +## Feature gating + +Features that do not have a usable phone presentation are excluded through +their registration or enablement conditions. Mobile-specific gating must remain +orthogonal to AI entitlement and provider capabilities. + +Do not infer feature support from provider IDs. Shared capabilities determine +whether an operation exists; mobile context determines whether its presentation +is available. + +## Testing + +Use focused tests for viewport selection, navigation-stack behavior, command +scope, and mobile part factories. Use component fixtures or live workbench +validation for layout, touch interaction, virtual keyboard behavior, and narrow +viewports. + +## Change policy + +Update this specification only when viewport ownership, mobile composition, the +part-subclass pattern, or navigation contracts change. Do not append file maps, +CSS values, unfinished work, individual control behavior, or regression +narratives. + +## Related specifications -- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and a contextual right slot that swaps between the new session (+) button (when in a chat) and the account indicator 👤 (on the welcome / new session screen). -- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. -- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTitlebarPart. -- **SessionCompositeBar** (chat tabs) is hidden via CSS. -- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. - -### Viewport Classification - -`SessionsLayoutPolicy` classifies the viewport: -- **phone**: `width < 640px` -- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) -- **desktop**: `width ≥ 1024px` - -The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTitlebarPart lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. - -### Context Keys - -| Key | Type | Purpose | -|-----|------|---------| -| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | -| `sessionsKeyboardVisible` | `boolean` | `true` when the virtual keyboard is visible | - -### Desktop → Mobile Component Mapping - -| Desktop Component | Mobile Equivalent | How Accessed | -|---|---|---| -| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +|👤) | Always visible at top | -| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | -| **Sessions Part** (chat views) | Same Part (`MobileSessionsPart`), edge-to-edge, no card chrome, single visible session | Default view (always visible) | -| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | -| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | -| **SessionCompositeBar** (chat tabs) | Hidden on phone | — | -| **New Session** (sidebar button) | + button in MobileTitlebarPart | Visible in top bar when in a chat | -| **Account indicator** (titlebar) | Account button in MobileTitlebarPart | Visible in top bar on welcome/new session | - -## File Map - -### Mobile Part Subclasses - -| File | Purpose | -|------|---------| -| `browser/parts/mobile/mobileSessionsPart.ts` | Extends `SessionsPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). Phone layout always shows a single `SessionView` filling the part. | -| `browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | -| `browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | -| `browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | - -### Mobile Chrome Components - -| File | Purpose | -|------|---------| -| `mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, contextual right slot (+ for in-chat, account indicator for welcome). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. Includes account state tracking, avatar loading, and account panel with copilot dashboard. | -| `browser/media/phoneLayout.css` | Shared phone-layout CSS imported by the sessions workbench: touch behavior, quick picks, dialogs, notifications, modal editors, and panel/auxiliary-bar overlays. | -| `mobileChatShell.css` | Phone chat-shell CSS: flex column layout, split-view positioning, card chrome removal, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, and chip row styling. | -| `mobilePickerSheet.ts` | Reusable phone-friendly bottom sheet for picker-style choices. Promise-based overlay with backdrop, drag handle, header (title + Done button + optional header actions), sectioned listbox, and optional inline search with debounced cancellable loads. Uses `DisposableStore` for lifecycle. | -| `media/mobilePickerSheet.css` | Styling for the bottom sheet widget (backdrop, slide-up animation, row layout, search input, section dividers, checkmarks). | -| `mobileChipLaneScroll.ts` | Pointer-event-based horizontal scroll helper for the config chip row. Overcomes monaco's `Gesture.addTarget` eating `touchmove` by translating `pointermove` into `scrollLeft` updates. Phone-gated via `isPhoneLayout()` — no-ops on desktop. | -| `mobileSessionFilterChips.ts` | Status filter-chip row shown below the sessions-list header on phone (Completed / In Progress / Failed). Drives the same filter API as `ISessionsList` so chips and the desktop filter menu stay in sync. | -| `mobileSortGroupSheet.ts` | `showMobileSortGroupSheet(...)`: bottom sheet presenting the sort and group toggles (with a divider between groups) as the phone replacement for the desktop sort/group menus. | -| `mobileVisualViewport.ts` | Tracks the `VisualViewport` to detect the virtual keyboard (threshold `KEYBOARD_VISIBLE_THRESHOLD_PX = 50`), drives the `sessionsKeyboardVisible` context key, and exposes the current keyboard height via the `--vscode-keyboard-height` CSS custom property. | -| `mobileEdgeSwipe.ts` | Left-edge swipe gesture that opens the sidebar drawer (edge hit zone, commit-travel and vertical-tolerance thresholds). | -| `mobilePulldownDismiss.ts` *(under `contributions/`)* | Pull-down-to-dismiss gesture for the full-screen overlays (commit by travel or flick velocity, with a dead-zone before visual feedback). | -| `longPress.ts` | `installLongPress(...)`: long-press gesture helper (hold-time + move-threshold) used for touch context actions, with click suppression after the press fires. | -| `contributions/mobileChangesView.ts` | Full-screen overlay listing every file changed in the active session (master view). Reactive over `ISessionsManagementService.activeSession.changes`. Each row uses a codicon change-type icon (`diffAdded` / `diffModified` / `diffRemoved` via `ThemeIcon.asClassNameArray`), filename, relative path, an A/M/D pill, and `+N -N` counters. Tapping a row invokes `MOBILE_OPEN_DIFF_VIEW_COMMAND_ID` with the per-file payload **plus** the full sibling list and index — the diff view uses that for prev/next chevrons. Replaces the legacy QuickPick the title-bar Changes pill used to open. | -| `contributions/mobileDiffView.ts` | Full-screen overlay rendering a unified diff for one file (detail view). Uses `linesDiffComputers.getDefault()` for hunk computation and async `tokenizeToString` from `editor/common/languages/textToHtmlTokenizer.ts` for Monaco-quality syntax highlighting. After tokenization, a per-render `