diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md
index d1933fe693c..9da66d0bb5d 100644
--- a/.github/skills/sessions/SKILL.md
+++ b/.github/skills/sessions/SKILL.md
@@ -194,6 +194,10 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru
- **Auto-managed tabs stay user-closable via "add only when the group is empty" — not a dismissal set**: `SinglePaneManagedTabsStrategy` owns the managed Changes/Files docked tabs. They re-ensure on many signals (session state, editor visibility, editor changes), so naively re-creating them makes a close feel un-closable (they are non-preview `pinEditor`, NOT sticky — they *do* have close buttons; the blocker is the re-ensure). The clean rule that needs **no** `_dismissedManagedTabs` bookkeeping: **open the default tabs only when the editor group is completely empty (`group.editors.length === 0`), and only on a "view opened" trigger** — a session switch (the add-allowed session-state autorun) or the layout service's `onDidRevealSidePane` event (fired by the workbench whenever the docked editor part and/or aux-bar detail transitions from *fully hidden* to visible). A plain editor-list / visibility change reconciles (e.g. removes the Files placeholder while a real file is open) but is **add-disallowed**. Why this is close-respecting for free: closing one managed tab while another (or a real file) remains leaves the group non-empty → not re-added; closing the last one closes the side pane → reopening it (empty group) restores the defaults. **Opening a file** fires `onDidRevealSidePane` too, but the sync is deferred on the docked-tab sequencer (which runs after `onWillOpenEditor` has added the editor), so the group is non-empty when it runs → defaults are not forced back → closing that file still closes the side pane. The add-disallowed editor-change trigger is essential: without it, closing the last tab (group empty) would immediately re-add the defaults and the pane could never close. **The layout-driven add is done on the *settled* restore, not during it**: the base controller fires `onDidEndSessionLayoutRestore` when the restore depth returns to 0 (after the — possibly async — working-set apply completes), exposed via `ISinglePaneLayoutContext`; the strategy reconciles off that ([Trigger D], `openDefaultsIfEmpty: true`). This is required for a **new session**, whose *empty* working set closes the previous session's docked tabs *after* the switch — reading the group *during* the async apply (an editor-change trigger) races the empty state and drops the Files tab; reconciling on the settled restore-end reads the reliably-empty group. Do **not** gate the add on `isRestoringSessionLayout` captured in the editor-change autorun — that fires mid-apply and is fragile. A *user* file-open/close is not a restore, so it stays add-disallowed and a close still sticks. **One exception — new-session submit**: when the active session transitions `isCreated` false → true (in place, or via a resource-replace commit), the new-session view already holds the Files placeholder, so the empty-group rule would skip opening Changes; the submit transition is treated as a one-shot "ensure the Changes tab (pinned first, **opened active**)" even when the group is non-empty — opening it *active* (not `inactive`) is what makes the detail panel map to the Changes container rather than the still-present Files placeholder; it is a genuine one-time transition, so it never fights a later user close. **Because submit fires two triggers** (the session-state autorun's `ensureChangesActive` **and**, via the submit restore, `onDidEndSessionLayoutRestore`'s Trigger D), a single shared generation counter would let the later trigger's reconcile supersede and drop the earlier's intent — so the triggers' intents are **accumulated** (`mergeTriggers`, OR-combined into a pending trigger consumed by the surviving reconcile, re-merged in `finally` if superseded mid-run) rather than replaced. **Scope the pending intents to the session they were queued for** (`IPendingReconcile.sessionKey` = the active session resource): a reconcile can be superseded mid-`await` (e.g. it stalls opening the Changes editor) by a **session switch**; if the superseded reconcile's `finally` merged its old trigger back **unconditionally**, an `ensureChangesActive`/`ensureAllInputs` intent for session A would leak onto session B and reopen a user-closed tab or activate Changes for the wrong session. Merge back (and accumulate on queue) **only when the successor targets the same `sessionKey`**; a session switch drops the previous session's stale intents. **Second exception — a details-only reveal**: when `onDidRevealSidePane` fires with the aux-bar detail panel visible but the editor area hidden (`isVisible(AUXILIARYBAR_PART) && !isVisible(EDITOR_PART)`), the docked details panel *shows* the managed docked inputs, so they are ensured (Changes if created + Files) **even when the group is non-empty** — restoring one the user had closed earlier. This is tied to the reveal gesture (a close *within* an open details view still sticks until the next reveal); an editor-included reveal keeps the strict empty-group rule. **Do NOT** re-introduce a `_dismissedManagedTabs` set, an `onDidCloseEditor` dismissal listener, infer the reopen from aux-bar visibility, or gate on a generic "side pane became visible (`editor || aux`)" check. The empty Files placeholder is tidied away when a real workspace file **opens** — a **one-shot** reaction on `onWillOpenEditor` (a real `file`/`vscode-remote` input, skipped during a restore), *not* a standing "no placeholder while a real file is open" invariant enforced every reconcile. The standing invariant broke `+` Files: adding the placeholder while a file was open re-triggered the reconcile which immediately removed it again. Because `+` Files opens an `EmptyFileEditorInput` (not a real file), the one-shot listener ignores it, so a user-added Files tab survives while a real file is open (a tidy `[Changes][file]` strip still results from a real-file open).
+- **Aux-only managed inputs are a state invariant, not a reveal-time exception**: whenever the Auxiliary Bar is visible and Editor is hidden, every managed-tab reconcile must re-read that current composition and ensure both Changes and the empty Files input, even in a non-empty group. Do not capture Aux-only state on `onDidRevealSidePane`; queued work can run after the composition changes, and closing either managed input while Aux-only must restore it immediately.
+
+- **Observe single-pane part visibility as a signal when the composition matters**: deriving `editorVisible || auxiliaryBarVisible` suppresses Editor+Aux → Aux-only transitions because the derived boolean stays `true`. Managed-tab reconciliation must react to every relevant part-visibility event, then read the settled Editor/Aux composition when queued work executes.
+
- **Editor-area collapse (closing non-docked tabs) fires only on a *detail-only* hide, never when the whole side pane closes**: `SinglePaneEditorAreaCollapseStrategy` reacts to the editor part hiding by closing every non-docked editor (capturing reopenable ones, dropping non-restorable ones). It must gate that on the **aux bar still being visible** (`isVisible(AUXILIARYBAR_PART)`): a *Detail-only* hide (Hide Editor keeps the detail) collapses the editors, but closing the **whole side pane** (both editor + aux hidden) must leave the editors intact so they return when the pane is reopened. The gate is reliable because the two hide paths order their `setPartHidden` calls consistently — `toggleSidePane` hides the **aux bar before the editor**, so when the editor-hidden event fires the aux is already hidden (⇒ skip collapse); `Hide Editor` sets the aux visible *before* hiding the editor (⇒ aux visible ⇒ collapse). Don't collapse purely off "editor part hidden" — that also dropped dirty/non-restorable editors when the user just closed the side pane.
- **D10 (empty aux-bar cleanup) must gate on quick-chat, not the racy container-active check, or it flickers the side pane closed on reload**: the Agents-window Changes/Files aux-bar views gate on `SessionHasWorkspaceContext` + `WorkspaceFolderCountContext`, which are set **asynchronously** (via the `setActiveSessionContextKeys` autorun reading the session's async `workspace`) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar, `isViewContainerActive(Files/Changes)` is transiently `false` (context keys not settled) even for a real workspace session. The D10 reconcile (`_syncAuxiliaryBarPartVisibility`, which runs synchronously on the `onDidChangePartVisibility(visible)` signal and only ever hides) then closes the just-opened side pane, and since it never re-reveals, it stays closed — the reload "side pane opens then closes" flicker, "Files not shown when opening the side pane", and "new-session side-pane state not remembered". Fix: D10 hides only when the aux is **genuinely** empty for the active session's lifetime — no active session, or a **workspace-less quick chat** (`activeSession.isQuickChat?.get() === true`, its Changes+Files permanently gated off) — never for a workspace-backed session whose gating context keys are merely still settling. Do NOT use the transient `_hasActiveAuxViewContainers()` result to hide a workspace session's aux.
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 7b2cb150c2e..f819c45e99f 100644
--- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml
+++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml
@@ -129,10 +129,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts linux $(NPM_ARCH)
- condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
displayName: Mixin distro node modules
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml
index a050b442335..354bce1a47e 100644
--- a/build/azure-pipelines/alpine/product-build-alpine.yml
+++ b/build/azure-pipelines/alpine/product-build-alpine.yml
@@ -174,9 +174,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts linux $(NPM_ARCH)
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
displayName: Mixin distro node modules
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
diff --git a/build/azure-pipelines/common/checkNativeOptionalDeps.ts b/build/azure-pipelines/common/checkNativeOptionalDeps.ts
index da1a7195c9d..b85b5742632 100644
--- a/build/azure-pipelines/common/checkNativeOptionalDeps.ts
+++ b/build/azure-pipelines/common/checkNativeOptionalDeps.ts
@@ -17,9 +17,9 @@ import path from 'path';
//
// `findMissingNativeOptionalDep` is the reusable primitive that detects this.
// It is used from two places:
-// - The CLI entry point below runs after restoring or installing the root
-// node_modules in CI and fails the job so a poisoned cache is neither used
-// nor saved.
+// - The CLI entry point below runs after `npm ci` in the node_modules
+// cache-build jobs (.github/workflows/pr-node-modules.yml) and fails the
+// job so a poisoned cache is never saved.
// - The agent-SDK producer (build/agent-sdk/package.ts) runs it after its
// scratch `npm ci` so a binary-less tarball is never built and uploaded to
// the CDN.
@@ -54,11 +54,11 @@ export function findMissingNativeOptionalDep(nodeModulesDir: string, basePackage
// #region CLI entry point
//
-// Runs after the root node_modules is restored or installed in CI. Verifies
-// the repo-root node_modules has the per-platform package for the target so a
-// poisoned cache (base package present, native package silently skipped) is
-// neither used nor persisted. The optional CLI arguments override the current
-// platform and architecture for cross-architecture builds.
+// Runs after the root `npm ci` in the node_modules cache-build jobs (see
+// .github/workflows/pr-node-modules.yml), before the cache is saved. Verifies
+// the repo-root node_modules has the per-platform package for the current host
+// so a poisoned cache (base package present, native package silently skipped)
+// is never persisted.
// Base packages whose per-platform package (`--`) is
// required whenever the base package itself is installed.
@@ -79,8 +79,7 @@ function isCliInvocation(): boolean {
}
function main(): void {
- const platform = process.argv[2] ?? process.platform;
- const arch = process.argv[3] ?? process.arch;
+ const { platform, arch } = process;
if (!SUPPORTED_PLATFORMS.has(platform) || !SUPPORTED_ARCHS.has(arch)) {
console.log(`Skipping native optional-dependency check on unsupported ${platform}-${arch}.`);
return;
@@ -97,11 +96,11 @@ function main(): void {
}
if (errors.length > 0) {
- console.error('\x1b[1;31m*** Missing native optional-dependency packages in node_modules ***\x1b[0m');
+ console.error('\x1b[1;31m*** Missing native optional-dependency packages — refusing to save a poisoned node_modules cache ***\x1b[0m');
for (const err of errors) {
console.error(` - ${err}`);
}
- console.error('\nnpm does not fail when an optional dependency cannot be installed, so a fresh install or restored cache can be incomplete. Re-run a fresh `npm ci` (e.g. after bumping build/.cachesalt) to restore the missing package.');
+ console.error('\nnpm does not fail when an optional dependency cannot be installed, so this tree would poison the shared node_modules cache. Re-run a fresh `npm ci` (e.g. after bumping build/.cachesalt) to restore the package before the cache is saved.');
process.exit(1);
}
diff --git a/build/azure-pipelines/copilot/setup-steps.yml b/build/azure-pipelines/copilot/setup-steps.yml
index e9d0686df82..93a695800f9 100644
--- a/build/azure-pipelines/copilot/setup-steps.yml
+++ b/build/azure-pipelines/copilot/setup-steps.yml
@@ -83,10 +83,6 @@ steps:
displayName: Install vscode-capi dependencies
condition: and(succeeded(), ne(variables.BUILD_CACHE_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
- workingDirectory: $(Build.SourcesDirectory)
- displayName: Verify native optional dependency binaries
-
- script: |
set -e
mkdir -p .build
diff --git a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml
index d136d0b3157..221a23bda89 100644
--- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml
+++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml
@@ -102,10 +102,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts darwin $(VSCODE_ARCH)
- condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
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 64904dbcfac..29d8f2136c1 100644
--- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml
+++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml
@@ -112,9 +112,6 @@ steps:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts darwin $(VSCODE_ARCH)
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml
index 4b412131d85..4e2ecb9e779 100644
--- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml
+++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml
@@ -142,10 +142,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts linux $(NPM_ARCH)
- condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
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 359129416d1..33809608f71 100644
--- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml
+++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml
@@ -159,9 +159,6 @@ steps:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts linux $(NPM_ARCH)
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml
index 20de1f7ab45..9c6f39afa0a 100644
--- a/build/azure-pipelines/product-quality-checks.yml
+++ b/build/azure-pipelines/product-quality-checks.yml
@@ -104,9 +104,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/azure-pipelines/web/product-build-web-node-modules.yml b/build/azure-pipelines/web/product-build-web-node-modules.yml
index cc61a7a015a..e757bc918eb 100644
--- a/build/azure-pipelines/web/product-build-web-node-modules.yml
+++ b/build/azure-pipelines/web/product-build-web-node-modules.yml
@@ -79,10 +79,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
- condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml
index 36fefb59585..343b9ecec90 100644
--- a/build/azure-pipelines/web/product-build-web.yml
+++ b/build/azure-pipelines/web/product-build-web.yml
@@ -93,9 +93,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - script: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
- displayName: Verify native optional dependency binaries
-
- script: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml
index 1ed345ae8e9..2ff7fc1158b 100644
--- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml
+++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml
@@ -85,10 +85,6 @@ jobs:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts win32 $(VSCODE_ARCH)
- condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- displayName: Verify native optional dependency binaries
-
- powershell: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
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 9d7d86f53ed..430ac04debd 100644
--- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml
+++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml
@@ -100,9 +100,6 @@ steps:
displayName: Install dependencies
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
- - powershell: node build/azure-pipelines/common/checkNativeOptionalDeps.ts win32 $(VSCODE_ARCH)
- displayName: Verify native optional dependency binaries
-
- powershell: node build/azure-pipelines/distro/mixin-npm.ts
condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'))
displayName: Mixin distro node modules
diff --git a/build/lib/policies/exportPolicyData.ts b/build/lib/policies/exportPolicyData.ts
index 24978715ca3..11fdf481ea0 100644
--- a/build/lib/policies/exportPolicyData.ts
+++ b/build/lib/policies/exportPolicyData.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { execFileSync, execSync } from 'child_process';
+import { execFileSync, execSync, spawn } from 'child_process';
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join, resolve } from 'path';
@@ -66,7 +66,7 @@ function readPolicyData(path: string): ExportedPolicyDataDto {
return result;
}
-function runPolicyExport(codeScript: string, outputPath: string, userDataPath: string, extensionsPath: string, agents: boolean): void {
+function runPolicyExport(codeScript: string, outputPath: string, userDataPath: string, extensionsPath: string, agents: boolean): Promise {
const args = [
`--export-policy-data=${outputPath}`,
`--user-data-dir=${userDataPath}`,
@@ -76,14 +76,24 @@ function runPolicyExport(codeScript: string, outputPath: string, userDataPath: s
args.unshift('--agents');
}
- const command = `"${codeScript}" ${args.map(arg => `"${arg}"`).join(' ')}`;
const env = { ...process.env };
delete env['VSCODE_PORTABLE'];
delete env['VSCODE_APPDATA'];
- execSync(command, {
- cwd: rootPath,
- stdio: 'inherit',
- env,
+ return new Promise((resolve, reject) => {
+ const child = spawn(codeScript, args, {
+ cwd: rootPath,
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32',
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Policy export process exited with ${signal ? `signal ${signal}` : `code ${code}`}.`));
+ }
+ });
});
}
@@ -135,9 +145,17 @@ async function main(): Promise {
const agentsPath = join(temporaryRoot, 'a.jsonc');
console.log('Exporting policy data from the Workbench...');
- runPolicyExport(codeScript, workbenchPath, join(temporaryRoot, 'wu'), join(temporaryRoot, 'we'), false);
console.log('Exporting policy data from the Agents window...');
- runPolicyExport(codeScript, agentsPath, join(temporaryRoot, 'au'), join(temporaryRoot, 'ae'), true);
+ const exportResults = await Promise.allSettled([
+ runPolicyExport(codeScript, workbenchPath, join(temporaryRoot, 'wu'), join(temporaryRoot, 'we'), false),
+ runPolicyExport(codeScript, agentsPath, join(temporaryRoot, 'au'), join(temporaryRoot, 'ae'), true),
+ ]);
+ const exportErrors = exportResults
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
+ .map(result => result.reason);
+ if (exportErrors.length > 0) {
+ throw new AggregateError(exportErrors, 'Failed to export policy data.');
+ }
const mergedContent = serializePolicyData(mergePolicyData([
{ source: 'Workbench', data: readPolicyData(workbenchPath) },
diff --git a/cli/src/commands/agent_endpoints.rs b/cli/src/commands/agent_endpoints.rs
index 7449e021970..4b28fda6673 100644
--- a/cli/src/commands/agent_endpoints.rs
+++ b/cli/src/commands/agent_endpoints.rs
@@ -45,9 +45,10 @@ struct EndpointsDocument {
/// array is a valid, meaningful answer ("nothing is running right now"),
/// distinct from failing to resolve/read the registry itself.
pub async fn agent_endpoints(
- ctx: CommandContext,
+ mut ctx: CommandContext,
args: AgentEndpointsArgs,
) -> Result {
+ ctx.log = crate::log::Logger::new(crate::log::Level::Off);
let user_data_path = resolve_user_data_path(args.user_data_dir.as_deref());
let endpoints = agent_discovery::discover_live_endpoints(&ctx, args.user_data_dir.as_deref());
diff --git a/cli/src/commands/update.rs b/cli/src/commands/update.rs
index e50a2de3115..2b748e9516d 100644
--- a/cli/src/commands/update.rs
+++ b/cli/src/commands/update.rs
@@ -31,7 +31,7 @@ pub async fn update(ctx: CommandContext, args: StandaloneUpdateArgs) -> Result();
private readonly _onDidChangeSessionsThrottler = this._register(new ThrottledDelayer(500));
- private readonly _sessionFileMonitor = this._register(new MutableDisposable());
private readonly _cachedSessionItems = new Map();
private readonly _sessionsBeingCreatedViaFork = new Set();
private readonly _newSessionIds = new Set();
@@ -190,9 +187,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {
this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);
}
- if (e.affectsConfiguration(this.sessionFileMonitoringDisabledSettingId)) {
- this.updateSessionFileMonitoring();
- }
}));
this._register(this._promptsService.onDidChangeCustomAgents(() => {
this._customAgentLookupChanged = true;
@@ -200,7 +194,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
void this.createCustomAgentLookup();
}
}));
- this.updateSessionFileMonitoring();
+ if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) {
+ this.monitorSessionFiles();
+ }
this._sessionManager = new Lazy>(async () => {
try {
const sdkPackage = await this.getSDKPackage();
@@ -238,28 +234,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
this._sessionTracker = this.instantiationService.createInstance(CopilotCLISessionWorkspaceTracker);
}
- private shouldMonitorSessionFiles(): boolean {
- return this.configurationService.getNonExtensionConfig(this.sessionFileMonitoringDisabledSettingId) !== true;
- }
-
- private get sessionFileMonitoringDisabledSettingId(): string {
- return this._agentSessionsWorkspace.isAgentSessionsWorkspace
- ? AGENT_HOST_DEFAULT_SESSIONS_PROVIDER_SETTING_ID
- : COPILOT_CLI_HIDE_EXTENSION_HOST_EDITOR_SETTING_ID;
- }
-
- private updateSessionFileMonitoring(): void {
- const shouldMonitor = this.shouldMonitorSessionFiles();
- if (shouldMonitor === !!this._sessionFileMonitor.value) {
- return;
- }
- if (shouldMonitor) {
- this.monitorSessionFiles();
- } else {
- this._sessionFileMonitor.clear();
- }
- }
-
private async getSDKPackage(): Promise {
return this.copilotCLISDK.getPackage();
}
@@ -301,15 +275,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return this._sessionWorkingDirectories.get(sessionId);
}
- private triggerSessionsChangeEvent() {
- // If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.
- if (this._isGettingSessions > 0) {
- return;
- }
-
- this._onDidChangeSessionsThrottler.trigger(() => Promise.resolve(this._onDidChangeSessions.fire()));
- }
-
public createNewSessionId(): string {
const sessionId = generateUuid();
this._newSessionIds.add(sessionId);
@@ -320,12 +285,19 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return this._newSessionIds.has(sessionId);
}
+ private triggerSessionsChangeEvent(): void {
+ if (this._isGettingSessions > 0) {
+ return;
+ }
+ this._onDidChangeSessionsThrottler.trigger(() => Promise.resolve(this._onDidChangeSessions.fire()));
+ }
+
protected monitorSessionFiles(): void {
- const disposables = new DisposableStore();
+ const disposables = this._register(new DisposableStore());
try {
const sessionDir = joinPath(this.nativeEnv.userHome, '.copilot', 'session-state');
const watcher = disposables.add(this.fileSystem.createFileSystemWatcher(new RelativePattern(sessionDir, '**/*.jsonl')));
- disposables.add(watcher.onDidCreate(async (e) => {
+ disposables.add(watcher.onDidCreate(async e => {
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {
return;
@@ -344,18 +316,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
this.triggerSessionsChangeEvent();
}));
- disposables.add(watcher.onDidChange((e) => {
- // If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.
+ disposables.add(watcher.onDidChange(e => {
if (this._isGettingSessions > 0) {
return;
}
-
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {
return;
}
-
- // If we're already working on a session that we're aware of then no need to trigger a refresh.
if (Array.from(this._sessionWrappers.keys()).some(sessionId => e.path.includes(sessionId))) {
return;
}
@@ -367,28 +335,22 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
} catch (error) {
disposables.dispose();
this.logService.error('Failed to monitor Copilot CLI session files:', error);
- return;
}
- this._sessionFileMonitor.value = disposables;
}
+
async getSessionManager() {
return this._sessionManager.value;
}
- private _sessionChangeNotifierByKey = new SequencerByKey();
- private triggerOnDidChangeSessionItem(sessionId: string, reason: 'fileSystemChange' | 'statusChange') {
+ private readonly _sessionChangeNotifierByKey = new SequencerByKey();
+ private triggerOnDidChangeSessionItem(sessionId: string, reason: 'fileSystemChange' | 'statusChange'): void {
this._sessionChangeNotifierByKey.queue(sessionId, async () => {
- // lets wait for 500ms, as we could get a lot of change events in a short period of time.
- // E.g. if you have a session running in integrated terminal, then its possible we will see a lot of updates.
- // In such cases its best to just delay (throttle) by 500ms (we get that via the sequncer and this delay)
if (reason === 'fileSystemChange') {
await new Promise(resolve => disposableTimeout(resolve, 500, this._store));
- // If already getting all sessions, no point in triggering individual change event.
if (this._isGettingSessions > 0) {
return;
}
}
-
const sessionItem = await this.getSessionItemImpl(sessionId, reason === 'statusChange' ? 'inMemorySession' : 'disk', CancellationToken.None);
if (sessionItem) {
this._onDidChangeSession.fire(sessionItem);
@@ -1413,17 +1375,12 @@ function labelFromPrompt(prompt: string): string {
return stripReminders(prompt);
}
-/**
- * Extracts the session ID from a deleted events.jsonl file path.
- * Expected path format: //events.jsonl
- */
-function extractSessionIdFromEventPath(sessionDir: URI, deletedFileUri: URI): string | undefined {
- if (basename(deletedFileUri) !== 'events.jsonl') {
+function extractSessionIdFromEventPath(sessionDir: URI, eventUri: URI): string | undefined {
+ if (basename(eventUri) !== 'events.jsonl') {
return undefined;
}
- const parentDir = dirname(deletedFileUri);
- const parentOfParent = dirname(parentDir);
- if (parentOfParent.path !== sessionDir.path) {
+ const parentDir = dirname(eventUri);
+ if (dirname(parentDir).path !== sessionDir.path) {
return undefined;
}
return basename(parentDir);
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
index cd1bbd5f371..cb0b71adfc0 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
@@ -13,7 +13,6 @@ import { CancellationToken } from 'vscode-languageserver-protocol';
import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';
import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/common/chatDebugFileLoggerService';
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
-import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
@@ -263,90 +262,28 @@ describe('CopilotCLISessionService', () => {
// --- Tests ----------------------------------------------------------------------------------
- describe('session file monitoring', () => {
- it('skips the watcher when the Extension Host Copilot CLI is inactive for the current window', async () => {
- const cases = [
- { name: 'Agents window Agent Host default', isAgentSessionsWorkspace: true, agentsDefault: true, editorHidden: false, editorDefault: false, expectedWatcherCount: 0 },
- { name: 'editor window Extension Host hidden', isAgentSessionsWorkspace: false, agentsDefault: false, editorHidden: true, editorDefault: false, expectedWatcherCount: 0 },
- { name: 'Agents window editor hidden only', isAgentSessionsWorkspace: true, agentsDefault: false, editorHidden: true, editorDefault: false, expectedWatcherCount: 1 },
- { name: 'editor window Agents default only', isAgentSessionsWorkspace: false, agentsDefault: true, editorHidden: false, editorDefault: false, expectedWatcherCount: 1 },
- { name: 'editor window Agent Host default only', isAgentSessionsWorkspace: false, agentsDefault: false, editorHidden: false, editorDefault: true, expectedWatcherCount: 1 },
- ];
+ it('monitors external sessions only in the Agents window', () => {
+ const editorFileSystem = new TrackingFileSystemService();
+ const agentsFileSystem = new TrackingFileSystemService();
+ const editorService = createSessionService({ fileSystem: editorFileSystem });
+ const agentsService = createSessionService({ fileSystem: agentsFileSystem, isAgentSessionsWorkspace: true });
- const results = [];
- for (const testCase of cases) {
- const testConfiguration = disposables.add(new InMemoryConfigurationService(configurationService));
- await Promise.all([
- testConfiguration.setNonExtensionConfig('chat.agentHost.defaultSessionsProvider', testCase.agentsDefault),
- testConfiguration.setNonExtensionConfig('chat.editor.copilotCli.hideExtensionHost', testCase.editorHidden),
- testConfiguration.setNonExtensionConfig('chat.defaultToCopilotHarness', testCase.editorDefault),
- ]);
- const fileSystem = new TrackingFileSystemService();
- disposables.add(createSessionService({
- configurationService: testConfiguration,
- fileSystem,
- isAgentSessionsWorkspace: testCase.isAgentSessionsWorkspace,
- }));
- results.push({ name: testCase.name, watcherCount: fileSystem.createFileSystemWatcherCallCount });
- }
+ const beforeDispose = {
+ editor: editorFileSystem.createFileSystemWatcherCallCount,
+ agents: agentsFileSystem.createFileSystemWatcherCallCount,
+ };
+ editorService.dispose();
+ agentsService.dispose();
- expect(results).toEqual(cases.map(testCase => ({ name: testCase.name, watcherCount: testCase.expectedWatcherCount })));
- });
-
- it('stops monitoring when the Agents window Agent Host default resolves after construction', async () => {
- const testConfiguration = disposables.add(new InMemoryConfigurationService(configurationService));
- await testConfiguration.setNonExtensionConfig('chat.agentHost.defaultSessionsProvider', false);
- const fileSystem = new TrackingFileSystemService();
- const sessionService = disposables.add(createSessionService({
- configurationService: testConfiguration,
- fileSystem,
- isAgentSessionsWorkspace: true,
- }));
- const states = [{ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount }];
-
- await testConfiguration.setNonExtensionConfig('chat.agentHost.defaultSessionsProvider', true);
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- await testConfiguration.setNonExtensionConfig('chat.agentHost.defaultSessionsProvider', false);
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- sessionService.dispose();
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- expect(states).toEqual([
- { created: 1, disposed: 0 },
- { created: 1, disposed: 1 },
- { created: 2, disposed: 1 },
- { created: 2, disposed: 2 },
- ]);
- });
-
- it('updates monitoring when the Extension Host Copilot CLI is hidden in the editor window', async () => {
- const testConfiguration = disposables.add(new InMemoryConfigurationService(configurationService));
- await testConfiguration.setNonExtensionConfig('chat.editor.copilotCli.hideExtensionHost', false);
- const fileSystem = new TrackingFileSystemService();
- const sessionService = disposables.add(createSessionService({
- configurationService: testConfiguration,
- fileSystem,
- isAgentSessionsWorkspace: false,
- }));
- const states = [{ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount }];
-
- await testConfiguration.setNonExtensionConfig('chat.editor.copilotCli.hideExtensionHost', true);
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- await testConfiguration.setNonExtensionConfig('chat.editor.copilotCli.hideExtensionHost', false);
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- sessionService.dispose();
- states.push({ created: fileSystem.createFileSystemWatcherCallCount, disposed: fileSystem.disposeFileSystemWatcherCallCount });
-
- expect(states).toEqual([
- { created: 1, disposed: 0 },
- { created: 1, disposed: 1 },
- { created: 2, disposed: 1 },
- { created: 2, disposed: 2 },
- ]);
+ expect({
+ beforeDispose,
+ disposed: {
+ editor: editorFileSystem.disposeFileSystemWatcherCallCount,
+ agents: agentsFileSystem.disposeFileSystemWatcherCallCount,
+ },
+ }).toEqual({
+ beforeDispose: { editor: 0, agents: 1 },
+ disposed: { editor: 0, agents: 1 },
});
});
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
index ef12e45b881..7d57cf4842d 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
@@ -386,10 +386,9 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
item.timing = session.timing;
item.status = session.status ?? vscode.ChatSessionStatus.Completed;
- // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
- // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
- // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
- if (options?.includeChanges || ((await this.hasCachedChanges(session.id, worktreeProperties)))) {
+ // Building changes is expensive, so defer it to explicit resolve and refresh paths
+ // when lazy loading is enabled. Preserve eager loading when it is disabled.
+ if (options?.includeChanges || !this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
if (token.isCancellationRequested) {
return item;
@@ -443,17 +442,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
return badge;
}
- private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise {
- if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
- return true;
- }
- const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
- this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId),
- this._workspaceFolderService.hasCachedChanges(sessionId)
- ]);
- return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
- }
-
private async buildChanges(
sessionId: string,
worktreeProperties: Awaited>,
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
index d99e1ae56cb..adc0655ee6e 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
@@ -332,13 +332,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
}
// Statistics (only returned for trusted workspace/worktree folders).
- // `getWorktreeChanges`/`getWorkspaceChanges` shell out to `git diff` and dominate the cost
- // of building an item — defer to `resolveChatSessionItem` for visible items.
- // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
- // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
- // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
+ // Building changes is expensive, so defer it to explicit resolve and refresh paths
+ // when lazy loading is enabled. Preserve eager loading when it is disabled.
let changes: vscode.ChatSessionChangedFile[] | undefined;
- if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {
+ if (!token.isCancellationRequested && (options?.includeChanges || !this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem))) {
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
// We need to get an updated version of worktree properties here because when the
// changes are being computed, the worktree properties are also updated with the
@@ -453,18 +450,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
} satisfies vscode.ChatSessionItem;
}
- private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise {
- if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
- return true;
- }
- const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
- this.worktreeManager.hasCachedChanges(sessionId),
- this.workspaceFolderService.hasCachedChanges(sessionId)
- ]);
- return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
- }
-
-
private async buildChanges(
sessionId: string,
worktreeProperties: Awaited>,
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
index dd712cf0895..f6a814c8e62 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
@@ -98,7 +98,7 @@ class TestWorktreeService extends mock() {
declare readonly _serviceBrand: undefined;
override getWorktreeProperties = vi.fn(async (_sessionId: string | vscode.Uri): Promise => undefined);
override setWorktreeProperties = vi.fn(async () => { });
- override getWorktreeChanges = vi.fn(async () => []);
+ override getWorktreeChanges = vi.fn(async () => []);
override hasCachedChanges = vi.fn(async () => false);
override onDidChangeWorktreeChanges = Event.None;
}
@@ -509,6 +509,47 @@ describe('CopilotCLIChatSessionContentProvider (additional)', () => {
expect(item.label).toBe('Test Session');
});
+ it('only includes cached changes when explicitly requested', async () => {
+ const { provider, worktreeService } = createProvider();
+ const sessionItem: ICopilotCLISessionItem = {
+ id: 'session-1',
+ label: 'Test Session',
+ timing: undefined,
+ workingDirectory: undefined,
+ };
+ worktreeService.getWorktreeProperties.mockResolvedValue({
+ version: 1,
+ baseCommit: 'base',
+ branchName: 'branch',
+ repositoryPath: '/repository',
+ worktreePath: '/worktree',
+ autoCommit: true,
+ });
+ worktreeService.hasCachedChanges.mockResolvedValue(true);
+ worktreeService.getWorktreeChanges.mockResolvedValue([
+ {
+ uri: vscodeShim.Uri.file('/repository/file'),
+ originalUri: undefined,
+ modifiedUri: vscodeShim.Uri.file('/repository/file'),
+ insertions: 3,
+ deletions: 1,
+ },
+ ]);
+
+ const listedItem = await provider.toChatSessionItem(sessionItem);
+ const resolvedItem = await provider.toChatSessionItem(sessionItem, { includeChanges: true });
+
+ expect({
+ listedChanges: listedItem.changes,
+ resolvedChanges: resolvedItem.changes?.length,
+ buildCount: worktreeService.getWorktreeChanges.mock.calls.length,
+ }).toEqual({
+ listedChanges: undefined,
+ resolvedChanges: 1,
+ buildCount: 1,
+ });
+ });
+
it('does not call refreshSession when PR detection finds no update', async () => {
const { provider, prDetectionService, worktreeService } = createProvider();
const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue();
diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts
index 9c18788fb65..5acb7ec71f4 100644
--- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts
+++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts
@@ -84,6 +84,51 @@ export function buildReasoningEffortSchemaProperty(effortLevels: readonly string
};
}
+/**
+ * Returns the localized, title-cased picker label for an Auto routing tier.
+ * Falls back to capitalizing an unknown value.
+ */
+export function getAutoModeTierLabel(tier: string): string {
+ switch (tier) {
+ case 'eco': return l10n.t('Eco');
+ case 'balanced': return l10n.t('Balanced');
+ case 'max': return l10n.t('Max');
+ case 'fast': return l10n.t('Fast');
+ default: return tier.charAt(0).toUpperCase() + tier.slice(1);
+ }
+}
+
+/**
+ * Returns the localized description shown in the picker hover for an Auto
+ * routing tier. Falls back to the raw tier for unknown values.
+ */
+export function getAutoModeTierDescription(tier: string): string {
+ switch (tier) {
+ case 'eco': return l10n.t('Cheaper models for everyday tasks');
+ case 'balanced': return l10n.t('Balances capability and cost');
+ case 'max': return l10n.t('Most capable models, higher cost');
+ case 'fast': return l10n.t('Lowest latency models');
+ default: return tier;
+ }
+}
+
+/**
+ * Builds the `tier` property descriptor for the Auto model's
+ * {@link LanguageModelConfigurationSchema}. Rendered by the model picker the
+ * same way thinking effort is, but labelled "Tier".
+ */
+export function buildAutoModeTierSchemaProperty(tiers: readonly string[], defaultTier: string): NonNullable[string] {
+ return {
+ type: 'string',
+ title: l10n.t('Tier'),
+ enum: [...tiers],
+ enumItemLabels: tiers.map(getAutoModeTierLabel),
+ enumDescriptions: tiers.map(getAutoModeTierDescription),
+ default: defaultTier,
+ group: 'navigation',
+ };
+}
+
/**
* Returns a description of the model's capabilities and intended use cases.
* This is shown in the rich hover when selecting models.
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
index eedab16ad60..d4f08de8642 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
@@ -13,6 +13,7 @@ import { ChatFetchResponseType, ChatLocation, getErrorDetailsFromChatFetchError
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { getTextPart } from '../../../platform/chat/common/globalStringUtils';
import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer';
+import { AUTO_MODE_TIER_PROPERTY, defaultAutoModeTier, selectableAutoModeTiers } from '../../../platform/endpoint/common/autoModeTiers';
import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes';
import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer';
@@ -44,7 +45,7 @@ import { IExtensionContribution } from '../../common/contributions';
import { PromptRenderer } from '../../prompts/node/base/promptRenderer';
import { isImageDataPart } from '../common/languageModelChatMessageHelpers';
import { LanguageModelAccessPrompt } from './languageModelAccessPrompt';
-import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess';
+import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess';
/**
* Markers in the autoModelHint experiment variable that indicate the auto model
@@ -125,13 +126,16 @@ function buildAutoRoutingContext(
// Key by the calling extension. Like a panel conversation, the first prompt
// picks the model and later ones reuse it, which bounds the cache at one
// entry per extension.
- return { prompt, sessionId: `vscode.lm:${options.requestInitiator ?? 'unknown'}`, references };
+ return { prompt, sessionId: `vscode.lm:${options.requestInitiator ?? 'unknown'}`, references, modelConfiguration: options.modelConfiguration };
}
-// Auto model delegates to different backends, so don't expose config pickers
-function buildConfigurationSchema(endpoint: IChatEndpoint, preferLongContext: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } {
+// Auto model delegates to different backends, so the only picker it exposes is
+// the routing tier; per-model options belong to the model it routes to.
+function buildConfigurationSchema(endpoint: IChatEndpoint, preferLongContext: boolean, autoTiersEnabled: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } {
if (endpoint instanceof AutoChatEndpoint) {
- return {};
+ return autoTiersEnabled
+ ? { configurationSchema: { properties: { [AUTO_MODE_TIER_PROPERTY]: buildAutoModeTierSchemaProperty(selectableAutoModeTiers, defaultAutoModeTier) } } }
+ : {};
}
const properties: Record[string]> = {};
@@ -299,6 +303,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
void this._refreshUtilityOverrides();
this._onDidChange.fire();
}));
+ this._register(this._automodeService.onDidChangeAutoModeTierSupport(() => {
+ // Withdraws (or restores) the Auto model's tier picker, which is only
+ // honored while routing goes through `POST /auto`.
+ this._onDidChange.fire();
+ }));
}
private async _provideLanguageModelChatInfo(options: { silent: boolean }, token: vscode.CancellationToken): Promise {
@@ -329,6 +338,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
const seenFamilies = new Set();
const preferLongContext = this._configurationService.getConfig(ConfigKey.PreferLongContext);
+ const autoTiersEnabled = this._automodeService.areAutoModeTiersSupported();
for (const endpoint of chatEndpoints) {
if (seenFamilies.has(endpoint.family) && !endpoint.showInModelPicker) {
@@ -414,7 +424,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision,
toolCalling: endpoint.supportsToolCalls,
},
- ...buildConfigurationSchema(endpoint, preferLongContext),
+ ...buildConfigurationSchema(endpoint, preferLongContext, autoTiersEnabled),
};
models.push(model);
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
index bf115302d19..525fbe62184 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
@@ -213,6 +213,8 @@ suite('LanguageModelAccess model info', () => {
resolveAutoModeEndpoint: async () => endpoint,
resolveAutoModePickerEndpoint: async () => endpoint,
getAutoPickerMetadata: async () => undefined,
+ areAutoModeTiersSupported: () => false,
+ onDidChangeAutoModeTierSupport: Event.None,
consumeLastRoutingDecision: () => undefined,
invalidateRouterCache: () => { },
} as unknown as IAutomodeService);
diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts
index 286b6f20e98..23fb081bf70 100644
--- a/extensions/copilot/src/extension/test/node/services.ts
+++ b/extensions/copilot/src/extension/test/node/services.ts
@@ -51,6 +51,7 @@ import { TestLogService } from '../../../platform/testing/common/testLogService'
import { ITestProvider } from '../../../platform/testing/common/testProvider';
import { IGithubAvailableEmbeddingTypesService, MockGithubAvailableEmbeddingTypesService } from '../../../platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes';
import { IWorkspaceChunkSearchService, NullWorkspaceChunkSearchService } from '../../../platform/workspaceChunkSearch/node/workspaceChunkSearchService';
+import { Event } from '../../../util/vs/base/common/event';
import { DisposableStore } from '../../../util/vs/base/common/lifecycle';
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
import { ILanguageModelServer } from '../../agents/node/langModelServer';
@@ -217,5 +218,11 @@ class NullAutomodeService implements IAutomodeService {
return undefined;
}
+ areAutoModeTiersSupported(): boolean {
+ return false;
+ }
+
+ readonly onDidChangeAutoModeTierSupport = Event.None;
+
invalidateRouterCache(): void { }
}
diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts
index 16465ae3323..8cb5f45b372 100644
--- a/extensions/copilot/src/platform/configuration/common/configurationService.ts
+++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts
@@ -632,6 +632,13 @@ export namespace ConfigKey {
* Experiment-based so it can be remotely disabled; an explicit user setting still wins.
*/
export const AutoModeV2Enabled = defineSetting('chat.autoMode.v2.enabled', ConfigType.ExperimentBased, true, undefined, undefined, { experimentName: 'copilotchat.autoModeV2Enabled' });
+
+ /**
+ * Offer routing tiers on the Auto model. Requires {@link AutoModeV2Enabled},
+ * since `tier` is only understood by `POST /auto`. Off by default: while
+ * disabled no tier is sent and the server picks its own routing profile.
+ */
+ export const AutoModeTiersEnabled = defineSetting('chat.autoMode.tiers.enabled', ConfigType.ExperimentBased, false, undefined, undefined, { experimentName: 'copilotchat.autoModeTiersEnabled' });
export const CLIModelDetailsEnabled = defineSetting('chat.agent.modelDetails.enabled', ConfigType.Simple, true);
export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true);
export const CLIChatLazyLoadSessionItem = defineSetting('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true);
@@ -752,6 +759,13 @@ export namespace ConfigKey {
/** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */
export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null);
+ /**
+ * Internal: override the routing tier sent to `POST /auto`, ignoring both the
+ * model picker and the tier inline chat defaults to. Unlike the picker this
+ * accepts `fast`, so evals can exercise every profile.
+ */
+ export const AutoModeTierOverride = defineSetting('chat.autoModeTierOverride', ConfigType.Simple, null);
+
/**
* When enabled, periodic keep-alive probes are sent during long-running tool calls
* to keep the server-side prompt cache warm.
diff --git a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts
new file mode 100644
index 00000000000..3a3e384af9a
--- /dev/null
+++ b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts
@@ -0,0 +1,39 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/**
+ * Routing profiles accepted by `POST /auto`. A tier is picked per session and
+ * biases which models the router may choose from.
+ */
+export const autoModeTiers = ['eco', 'balanced', 'max', 'fast'] as const;
+
+export type AutoModeTier = typeof autoModeTiers[number];
+
+/**
+ * The tiers offered in the model picker. `fast` is excluded: it is the profile
+ * inline chat falls back to when the user has not picked a tier, and is not
+ * offered as a choice. It remains reachable through the internal
+ * {@link ConfigKey.Advanced.AutoModeTierOverride} setting.
+ */
+export const selectableAutoModeTiers: readonly AutoModeTier[] = ['eco', 'balanced', 'max'];
+
+/** The tier used when the user has not picked one. */
+export const defaultAutoModeTier: AutoModeTier = 'balanced';
+
+/** The tier inline chat defaults to; latency matters more than routing depth there. */
+export const inlineChatAutoModeTier: AutoModeTier = 'fast';
+
+/** Key the selected tier is stored under in the Auto model's configuration. */
+export const AUTO_MODE_TIER_PROPERTY = 'tier';
+
+/**
+ * Narrows an untrusted value (persisted model configuration, or configuration
+ * supplied by a third-party extension through the `vscode.lm` API) to a tier the
+ * picker offers. `fast` is rejected so it stays an internal default rather than
+ * something a caller can select.
+ */
+export function isSelectableAutoModeTier(value: unknown): value is AutoModeTier {
+ return typeof value === 'string' && (selectableAutoModeTiers as readonly string[]).includes(value);
+}
diff --git a/extensions/copilot/src/platform/endpoint/node/autoV2Fetcher.ts b/extensions/copilot/src/platform/endpoint/node/autoV2Fetcher.ts
index d87989a0087..ca617d7f8c7 100644
--- a/extensions/copilot/src/platform/endpoint/node/autoV2Fetcher.ts
+++ b/extensions/copilot/src/platform/endpoint/node/autoV2Fetcher.ts
@@ -10,6 +10,7 @@ import { ILogService } from '../../log/common/logService';
import { Response } from '../../networking/common/fetcherService';
import { IRequestLogger, LoggedRequestKind } from '../../requestLogger/common/requestLogger';
import { ITelemetryService } from '../../telemetry/common/telemetry';
+import type { AutoModeTier } from '../common/autoModeTiers';
import { ICAPIClientService } from '../common/capiClient';
import type { IModelAPIResponse } from '../common/endpointProvider';
@@ -72,6 +73,8 @@ export class AutoV2Fetcher {
multiTurn?: AutoV2MultiTurnState;
conversationId?: string;
vscodeRequestId?: string;
+ /** Routing profile for the session. Omitted lets the server pick its own default. */
+ tier?: AutoModeTier;
/**
* Set when the call only reads `discounted_costs` for the picker.
* Keeps the placeholder prompt out of telemetry and the request log.
@@ -87,6 +90,9 @@ export class AutoV2Fetcher {
if (options.multiTurn) {
requestBody.multi_turn = options.multiTurn;
}
+ if (options.tier) {
+ requestBody.tier = options.tier;
+ }
const copilotToken = (await this._authService.getCopilotToken()).token;
const abortController = new AbortController();
@@ -125,7 +131,7 @@ export class AutoV2Fetcher {
if (!result.selected_model?.id) {
throw new AutoV2Error('Auto response did not contain a selected model', response.status);
}
- this._logService.trace(`[AutoV2Fetcher] Selected model: ${result.selected_model.id} (e2e_latency_ms: ${e2eLatencyMs}, expires_at: ${result.expires_at})`);
+ this._logService.trace(`[AutoV2Fetcher] Selected model: ${result.selected_model.id} (tier: ${options.tier ?? 'server default'}, e2e_latency_ms: ${e2eLatencyMs}, expires_at: ${result.expires_at})`);
this._requestLogger.addEntry({
type: LoggedRequestKind.MarkdownContentRequest,
@@ -136,6 +142,7 @@ export class AutoV2Fetcher {
`# Auto Mode Decision (POST /auto)`,
`## Result`,
`- **Selected Model**: ${result.selected_model.id}`,
+ `- **Tier**: ${options.tier ?? 'server default'}`,
`- **Expires At**: ${new Date(result.expires_at * 1000).toISOString()}`,
`## Latency`,
`- **E2E Latency**: ${e2eLatencyMs}ms`,
@@ -154,6 +161,7 @@ export class AutoV2Fetcher {
"conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The conversation ID in which the selection was made." },
"vscodeRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The VS Code chat request id in which the selection was made." },
"selectedModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model the server selected for this prompt." },
+ "tier": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The routing profile requested for this selection, e.g. eco, balanced, max, fast. Empty when none was requested." },
"e2eLatencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The end-to-end latency of the auto request in milliseconds, including network overhead." },
"scoreReasoning": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Hydra per-dimension score for reasoning. -1 if not present in the response." },
"scoreCodeGen": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Hydra per-dimension score for code generation. -1 if not present in the response." },
@@ -166,6 +174,7 @@ export class AutoV2Fetcher {
conversationId: options.conversationId ?? '',
vscodeRequestId: options.vscodeRequestId ?? '',
selectedModel: result.selected_model.id,
+ tier: options.tier ?? '',
},
{
e2eLatencyMs,
diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts
index a57e422b56e..270ac0b858e 100644
--- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts
+++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts
@@ -7,6 +7,7 @@ import { RequestType } from '@vscode/copilot-api';
import type { ChatRequest } from 'vscode';
import { FetchedValue } from '../../../shared-fetch-utils/common/fetchedValue';
import { createServiceIdentifier } from '../../../util/common/services';
+import { Emitter, type Event } from '../../../util/vs/base/common/event';
import { Disposable, DisposableMap, MutableDisposable } from '../../../util/vs/base/common/lifecycle';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { ChatLocation } from '../../../vscodeTypes';
@@ -22,6 +23,7 @@ import { IChatEndpoint } from '../../networking/common/networking';
import { IRequestLogger } from '../../requestLogger/common/requestLogger';
import { IExperimentationService } from '../../telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../telemetry/common/telemetry';
+import { AUTO_MODE_TIER_PROPERTY, autoModeTiers, defaultAutoModeTier, inlineChatAutoModeTier, isSelectableAutoModeTier, type AutoModeTier } from '../common/autoModeTiers';
import { ICAPIClientService } from '../common/capiClient';
import type { IChatModelCapabilities, IChatModelInformation } from '../common/endpointProvider';
import { AutoChatEndpoint } from './autoChatEndpoint';
@@ -42,6 +44,8 @@ interface AutoV2CacheEntry {
/** UNIX seconds at which `sessionToken` expires. */
expiresAt: number;
lastRoutedPrompt?: string;
+ /** Routing profile the session was resolved with; a change re-routes. `undefined` while tiers are disabled. */
+ tier: AutoModeTier | undefined;
turnCount: number;
needsReEval: boolean;
}
@@ -118,6 +122,9 @@ class AutoModeTokenBank extends Disposable {
}
}
+/** Surfaces that default to the latency-oriented tier rather than {@link defaultAutoModeTier}. */
+const inlineChatLocations: ReadonlySet = new Set([ChatLocation.Editor, ChatLocation.Terminal, ChatLocation.Notebook]);
+
/**
* The subset of {@link ChatRequest} auto mode reads when routing. Callers that
* have a real `ChatRequest` pass it directly; callers that do not (e.g. the
@@ -131,6 +138,8 @@ export interface IAutoModeRoutingRequest {
readonly sessionId?: string;
readonly sessionResource?: { toString(): string };
readonly references?: readonly { readonly value: unknown }[];
+ /** The picker configuration for the Auto model, which carries the selected tier. */
+ readonly modelConfiguration?: { readonly [key: string]: unknown };
}
export interface AutoModeRoutingDecision {
@@ -169,6 +178,19 @@ export interface IAutomodeService {
*/
getAutoPickerMetadata(): Promise;
+ /**
+ * Whether the Auto model should offer the tier picker. Tiers are a `POST /auto`
+ * concept, so the picker has to be withdrawn once routing falls back to the
+ * legacy flow. Changes are announced by {@link onDidChangeAutoModeTierSupport}.
+ */
+ areAutoModeTiersSupported(): boolean;
+
+ /**
+ * Fires when {@link areAutoModeTiersSupported} changes, so the Auto model's
+ * configuration schema can be republished.
+ */
+ readonly onDidChangeAutoModeTierSupport: Event;
+
/**
* Returns the routing decision from the last call to {@link resolveAutoModeEndpoint},
* or `undefined` if the router was not used (e.g. skipped, fallback, or non-auto model).
@@ -200,10 +222,16 @@ export class AutomodeService extends Disposable implements IAutomodeService {
private static readonly AUTO_V2_DISCOUNTS_STORAGE_KEY = 'copilot.autoMode.v2.lastDiscountedCosts';
/** Placeholder prompt used to read discounts. See {@link _probeAutoV2Discounts}. */
private static readonly DISCOUNT_PROBE_PROMPT = 'MODEL_PICKER_DISCOUNT_RESOLUTION - REPLACE ME';
+ /** Upper bound on live V2 sessions. See {@link _pruneAutoV2Cache}. */
+ private static readonly AUTO_V2_CACHE_MAX_ENTRIES = 50;
/** In-flight discount probe, so concurrent picker refreshes share one call. */
private _autoV2DiscountProbe: Promise | undefined;
/** Session used only to read discounts for the picker on the legacy flow. */
private readonly _pickerTokenBank = this._register(new MutableDisposable());
+ private readonly _onDidChangeAutoModeTierSupport = this._register(new Emitter());
+ readonly onDidChangeAutoModeTierSupport = this._onDidChangeAutoModeTierSupport.event;
+ /** Last announced {@link areAutoModeTiersSupported}. See {@link _updateAutoModeTierSupport}. */
+ private _tierSupportAnnounced = false;
constructor(
@ICAPIClientService private readonly _capiClientService: ICAPIClientService,
@@ -219,15 +247,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
) {
super();
this._lastAutoV2Discounts = this._extensionContext.globalState.get>(AutomodeService.AUTO_V2_DISCOUNTS_STORAGE_KEY);
+ this._tierSupportAnnounced = this.areAutoModeTiersSupported();
+ // Covers both settings and their experiment treatments: a treatment
+ // refresh is published as a configuration change.
+ this._register(this._configurationService.onDidChangeConfiguration(() => this._updateAutoModeTierSupport()));
this._register(this._authService.onDidAuthenticationChange(() => {
for (const entry of this._autoModelCache.values()) {
entry.tokenBank.dispose();
}
this._autoModelCache.clear();
this._autoV2Cache.clear();
- // All of this is scoped to the signed-in account.
+ // All of this is scoped to the signed-in account. Tier support can come
+ // back with the latch, but LanguageModelAccess already republishes
+ // models on this same event, so there is nothing to announce here.
this._setLastAutoV2Discounts(undefined);
this._autoV2Unavailable = false;
+ this._tierSupportAnnounced = this.areAutoModeTiersSupported();
this._autoV2DiscountProbe = undefined;
this._pickerTokenBank.clear();
const keys = Array.from(this._reserveTokens.keys());
@@ -257,7 +292,18 @@ export class AutomodeService extends Disposable implements IAutomodeService {
return decision;
}
- private _setLastAutoV2Discounts(discounts: Record | undefined): void {
+ /**
+ * Records the discounts shown on the Auto row in the picker. `tier` is the
+ * profile the discounts came from: tiers route to different model pools and
+ * so carry different discounts, while the picker has a single Auto row and no
+ * tier context to qualify it with. Scope the label to the profile the picker
+ * represents, so neither the internal `fast` tier (inline chat) nor another
+ * tier's routing pass overwrites it.
+ */
+ private _setLastAutoV2Discounts(discounts: Record | undefined, tier?: AutoModeTier): void {
+ if (tier !== undefined && tier !== defaultAutoModeTier) {
+ return;
+ }
if (JSON.stringify(this._lastAutoV2Discounts) === JSON.stringify(discounts)) {
return;
}
@@ -277,9 +323,18 @@ export class AutomodeService extends Disposable implements IAutomodeService {
if (!this._autoV2DiscountProbe) {
this._autoV2DiscountProbe = (async () => {
try {
- const result = await this._autoV2Fetcher.getAutoDecision(AutomodeService.DISCOUNT_PROBE_PROMPT, { isDiscountProbe: true });
+ const result = await this._autoV2Fetcher.getAutoDecision(AutomodeService.DISCOUNT_PROBE_PROMPT, {
+ isDiscountProbe: true,
+ // Read the same profile the label represents; see `_setLastAutoV2Discounts`.
+ tier: this.areAutoModeTiersSupported() ? defaultAutoModeTier : undefined,
+ });
this._setLastAutoV2Discounts(result.discounted_costs);
} catch (e) {
+ // A 404 is a capability result, not a metadata failure: the
+ // routing path treats it the same way.
+ if (e instanceof AutoV2Error && e.status === 404) {
+ this._markAutoV2Unavailable();
+ }
this._logService.warn(`[AutomodeService] Failed to probe auto discounts: ${(e as Error).message}`);
}
})();
@@ -291,20 +346,25 @@ export class AutomodeService extends Disposable implements IAutomodeService {
if (!knownEndpoints.length) {
throw new Error('No auto mode endpoints provided.');
}
- if (!this._isAutoV2Enabled()) {
+ if (!this.isAutoV2Enabled()) {
return this.resolveAutoModeEndpoint(undefined, knownEndpoints);
}
// Nothing to route without a prompt: wrap a representative endpoint for
// its display metadata only. The picker hides per-model pricing for
// Auto, so the wrapped model is not user-visible.
const metadata = await this.getAutoPickerMetadata();
+ // The probe above can latch V2 off (404), which changes what the picker
+ // may advertise.
+ if (!this.isAutoV2Enabled()) {
+ return this.resolveAutoModeEndpoint(undefined, knownEndpoints);
+ }
const discountRange = metadata?.discountRange ?? { low: 0, high: 0 };
const base = knownEndpoints.find(e => e.showInModelPicker) ?? knownEndpoints[0];
return this._instantiationService.createInstance(AutoChatEndpoint, base, '', 0, discountRange);
}
async getAutoPickerMetadata(): Promise {
- if (this._isAutoV2Enabled()) {
+ if (this.isAutoV2Enabled()) {
// `/auto` requires a prompt, which the picker does not have. Prefer
// the discounts observed on a real request; only when none have been
// seen yet (first ever run) probe with a placeholder prompt.
@@ -355,7 +415,7 @@ export class AutomodeService extends Disposable implements IAutomodeService {
// leak to a consumer if this call takes a non-router path.
this._lastRoutingDecision = undefined;
- if (this._isAutoV2Enabled()) {
+ if (this.isAutoV2Enabled()) {
const v2Endpoint = await this._tryResolveWithAutoV2(chatRequest, knownEndpoints);
if (v2Endpoint) {
return v2Endpoint;
@@ -489,10 +549,82 @@ export class AutomodeService extends Disposable implements IAutomodeService {
return autoEndpoint;
}
- private _isAutoV2Enabled(): boolean {
+ isAutoV2Enabled(): boolean {
return !this._autoV2Unavailable && this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AutoModeV2Enabled, this._expService);
}
+ areAutoModeTiersSupported(): boolean {
+ return this.isAutoV2Enabled() && this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AutoModeTiersEnabled, this._expService);
+ }
+
+ /**
+ * Latches V2 off for the rest of the session and withdraws the tier picker,
+ * which would otherwise stay visible while the legacy flow silently ignores it.
+ */
+ private _markAutoV2Unavailable(): void {
+ if (this._autoV2Unavailable) {
+ return;
+ }
+ this._autoV2Unavailable = true;
+ this._updateAutoModeTierSupport();
+ }
+
+ /**
+ * Announces a change in {@link areAutoModeTiersSupported}. Its inputs are the
+ * two settings (and their experiment treatments) plus the V2 latch, so this
+ * runs on every configuration change as well as after the latch flips.
+ */
+ private _updateAutoModeTierSupport(): void {
+ const supported = this.areAutoModeTiersSupported();
+ if (supported !== this._tierSupportAnnounced) {
+ this._tierSupportAnnounced = supported;
+ this._onDidChangeAutoModeTierSupport.fire();
+ }
+ }
+
+ /**
+ * The routing profile to request for a turn, in precedence order: the
+ * internal override setting, then an explicit picker selection, then the
+ * pin inline surfaces trade routing depth for latency with.
+ *
+ * Returns `undefined` while tiers are disabled, which omits `tier` from the
+ * request and leaves the routing profile to the service. The override is
+ * honored either way, so evals can exercise tiers before the experiment
+ * reaches them.
+ *
+ * The picker selection is honored on inline surfaces too. The schema is
+ * published per model rather than per surface, so the tier chip renders in
+ * inline chat as well; unconditionally pinning `fast` there would leave the
+ * user a visible, persisted control that silently does nothing.
+ *
+ * Only a non-default selection counts as explicit: the workbench materializes
+ * the schema default into `modelConfiguration` and strips a pick of the
+ * default back out when storing it, so a `balanced` entry cannot be told
+ * apart from "never picked" — reading it as a selection would make the inline
+ * pin below unreachable.
+ */
+ private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined {
+ const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride);
+ if (override) {
+ // The override is internal, so unlike the picker it may select `fast`.
+ if ((autoModeTiers as readonly string[]).includes(override)) {
+ return override as AutoModeTier;
+ }
+ this._logService.warn(`[AutomodeService] Ignoring auto tier override '${override}' — not one of [${autoModeTiers.join(', ')}].`);
+ }
+ if (!this.areAutoModeTiersSupported()) {
+ return undefined;
+ }
+ const configured = chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY];
+ if (isSelectableAutoModeTier(configured) && configured !== defaultAutoModeTier) {
+ return configured;
+ }
+ if (chatRequest?.location !== undefined && inlineChatLocations.has(chatRequest.location)) {
+ return inlineChatAutoModeTier;
+ }
+ return defaultAutoModeTier;
+ }
+
/**
* Resolves via `POST /auto`. Returns `undefined` when V2 cannot serve the
* request, so the caller falls back to the legacy flow.
@@ -500,18 +632,21 @@ export class AutomodeService extends Disposable implements IAutomodeService {
private async _tryResolveWithAutoV2(chatRequest: IAutoModeRoutingRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise {
const conversationId = chatRequest?.sessionResource?.toString() ?? chatRequest?.sessionId ?? 'unknown';
const prompt = chatRequest?.prompt?.trim();
- // `/auto` needs a prompt. Non-panel locations stay on the legacy flow,
- // which applies their location-specific model hints.
- if (!prompt?.length || conversationId === 'unknown' || !this._isRouterEnabled(chatRequest)) {
+ // `/auto` only needs a prompt and a conversation to key the session on;
+ // every surface routes, and the tier carries the surface's intent.
+ if (!prompt?.length || conversationId === 'unknown') {
return undefined;
}
+ const tier = this._resolveTier(chatRequest);
const entry = this._autoV2Cache.get(conversationId);
// The token lasts 24h with no refresh, so reuse the endpoint for the rest
- // of the conversation. A turn that newly attaches an image must
- // re-resolve, since the cached model was picked without that constraint.
+ // of the conversation. A turn that attaches an image to a text-only model
+ // must re-resolve, as must a turn whose tier no longer matches the routing
+ // profile the cached model was picked under.
const cacheUsable = entry && !entry.needsReEval && entry.turnCount > 0
&& !this._isAutoV2SessionExpired(entry)
+ && entry.tier === tier
&& (!hasImage(chatRequest) || entry.endpoint.supportsVision);
if (cacheUsable) {
return entry.endpoint;
@@ -522,8 +657,9 @@ export class AutomodeService extends Disposable implements IAutomodeService {
hasImage: hasImage(chatRequest),
conversationId,
vscodeRequestId: chatRequest?.id,
+ tier,
});
- this._setLastAutoV2Discounts(result.discounted_costs);
+ this._setLastAutoV2Discounts(result.discounted_costs, tier);
// Prefer local `/models` metadata: it carries fields `/auto` leaves
// unset (token pricing, promos, SKU restrictions, thinking budgets).
@@ -549,15 +685,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
return undefined;
}
- const endpoint = (entry?.endpoint && entry.sessionToken === result.session_token && entry.endpoint.model === selectedModel.model)
+ const endpoint = (entry?.endpoint && entry.sessionToken === result.session_token && entry.endpoint.model === selectedModel.model && entry.tier === tier)
? entry.endpoint
: this._instantiationService.createInstance(AutoChatEndpoint, selectedModel, result.session_token, result.discounted_costs?.[selectedModel.model] || 0, this._calculateDiscountRange(result.discounted_costs));
+ // Only a genuinely new conversation needs room made for it; the `set`
+ // below otherwise replaces an entry, and evicting would cost an
+ // unrelated session.
+ if (!this._autoV2Cache.has(conversationId)) {
+ this._evictOldestAutoV2Sessions();
+ }
this._autoV2Cache.set(conversationId, {
endpoint,
sessionToken: result.session_token,
expiresAt: result.expires_at,
lastRoutedPrompt: prompt,
+ tier,
turnCount: (entry?.turnCount ?? 0) + (entry?.lastRoutedPrompt === prompt ? 0 : 1),
needsReEval: false,
});
@@ -566,13 +709,14 @@ export class AutomodeService extends Disposable implements IAutomodeService {
const reason = this._classifyAutoV2Failure(e);
// A 404 means we are gated off; stop retrying on every turn.
if (e instanceof AutoV2Error && e.status === 404) {
- this._autoV2Unavailable = true;
+ this._markAutoV2Unavailable();
this._logService.info(`[AutomodeService] Auto v2 endpoint unavailable (404); using the legacy flow for the rest of the session.`);
}
this._logService.error(`[AutomodeService] Auto v2 failed for conversation ${conversationId} (${reason}):`, (e as Error).message);
this._sendAutoV2FallbackTelemetry(reason);
- // Prefer the last known good endpoint over the legacy round-trips.
- if (entry && !this._isAutoV2SessionExpired(entry) && (!hasImage(chatRequest) || entry.endpoint.supportsVision)) {
+ // Prefer the last known good endpoint over the legacy round-trips, but
+ // only when it still reflects the tier and vision needs of this turn.
+ if (entry && entry.tier === tier && !entry.needsReEval && !this._isAutoV2SessionExpired(entry) && (!hasImage(chatRequest) || entry.endpoint.supportsVision)) {
return entry.endpoint;
}
return undefined;
@@ -615,6 +759,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
return entry.expiresAt * 1000 - Date.now() < 5 * 60 * 1000;
}
+ /**
+ * Bounds the session cache. Inline chat starts a new session per invocation,
+ * so without this the map grows for the life of the window with conversations
+ * that will never be read again. Stale entries are already rejected when read,
+ * so this only has to reclaim memory: evict oldest-first (Map keeps insertion
+ * order) to make room for one more.
+ */
+ private _evictOldestAutoV2Sessions(): void {
+ for (const conversationId of this._autoV2Cache.keys()) {
+ if (this._autoV2Cache.size < AutomodeService.AUTO_V2_CACHE_MAX_ENTRIES) {
+ return;
+ }
+ this._autoV2Cache.delete(conversationId);
+ }
+ }
+
private _classifyAutoV2Failure(e: unknown): string {
if (isAbortError(e)) {
return 'autoV2Timeout';
@@ -795,6 +955,10 @@ export class AutomodeService extends Disposable implements IAutomodeService {
return fallbackEndpoint;
}
+ /**
+ * Gates the legacy router. Kept panel-only so the fallback path behaves
+ * exactly as it did before `/auto`; V2 routes every surface.
+ */
private _isRouterEnabled(chatRequest: IAutoModeRoutingRequest | undefined): boolean {
const isPanelChat = !chatRequest?.location || chatRequest?.location === ChatLocation.Panel;
return isPanelChat;
diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts
index ca206abebfd..b939c0b3d0d 100644
--- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts
+++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts
@@ -18,9 +18,10 @@ import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger
import { IExperimentationService, NullExperimentationService } from '../../../telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../../telemetry/common/telemetry';
import { createPngBytes } from '../../../image/common/test/testImageData';
-import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService';
+import { BaseConfig, ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService';
import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService';
import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService';
+import { defaultAutoModeTier } from '../../common/autoModeTiers';
import { ICAPIClientService } from '../../common/capiClient';
import { AutomodeService } from '../automodeService';
@@ -1406,13 +1407,25 @@ describe('AutomodeService', () => {
});
});
describe('single-call Auto endpoint (POST /auto)', () => {
- function enableAutoV2(): void {
+ function enableAutoV2(overrides: Map, unknown> = new Map()): void {
configurationService = new InMemoryConfigurationService(
new DefaultsOnlyConfigurationService(),
- new Map([[ConfigKey.Advanced.AutoModeV2Enabled, true]]),
+ new Map, unknown>([
+ [ConfigKey.Advanced.AutoModeV2Enabled, true],
+ ...overrides,
+ ]),
);
}
+ /** Tiers are experiment-gated and off by default, so tier tests opt in. */
+ function enableAutoV2WithTiers(): void {
+ enableAutoV2(new Map, unknown>([[ConfigKey.Advanced.AutoModeTiersEnabled, true]]));
+ }
+
+ function enableAutoV2WithTierOverride(override: string): void {
+ enableAutoV2(new Map, unknown>([[ConfigKey.Advanced.AutoModeTierOverride, override]]));
+ }
+
function makeAutoResponse(body: unknown, status = 200) {
const serialized = JSON.stringify(body);
return {
@@ -1699,8 +1712,9 @@ describe('AutomodeService', () => {
expect(second.model).toBe('gpt-4o-vision');
});
- it('does not call /auto for non-panel chat locations', async () => {
- enableAutoV2();
+ it('routes inline chat through /auto with the fast tier', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
mockAuto({
session_token: 'auto-v2-token',
expires_at: Math.floor(Date.now() / 1000) + 86400,
@@ -1708,16 +1722,424 @@ describe('AutomodeService', () => {
});
automodeService = createService();
- const chatRequest: Partial = {
+ for (const location of [ChatLocation.Editor, ChatLocation.Terminal, ChatLocation.Notebook]) {
+ const result = await automodeService.resolveAutoModeEndpoint({
+ location,
+ prompt: 'test prompt',
+ sessionId: `session-auto-v2-${location}`,
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ expect(result.model).toBe('gpt-4o');
+ }
+
+ const tiers = (mockCAPIClientService.makeRequest as ReturnType).mock.calls
+ .filter(c => c[1]?.type === RequestType.Auto)
+ .map(c => JSON.parse(c[0].body).tier);
+ expect(tiers).toEqual(['fast', 'fast', 'fast']);
+ });
+
+ // The workbench materializes the schema default into `modelConfiguration`,
+ // so this — not an absent `modelConfiguration` — is what a real inline
+ // request looks like for a user who never touched the tier picker.
+ it('pins inline chat to the fast tier when the picker sits on its default', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Editor,
+ prompt: 'inline turn',
+ sessionId: 'session-auto-v2-inline-default',
+ modelConfiguration: { tier: defaultAutoModeTier },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'inline turn', tier: 'fast' });
+ });
+
+ it('honors an explicit tier selection on inline surfaces', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
location: ChatLocation.Editor,
prompt: 'test prompt',
- sessionId: 'session-auto-v2-editor'
- };
+ sessionId: 'session-auto-v2-inline-tier',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
- await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [mockChatEndpoint]);
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'max' });
+ });
- const autoCalls = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.filter(c => c[1]?.type === RequestType.Auto);
- expect(autoCalls).toHaveLength(0);
+ it('sends the tier picked in the model configuration', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'test prompt',
+ sessionId: 'session-auto-v2-tier',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'max' });
+ });
+
+ it('falls back to the default tier when the configured tier is not user selectable', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'test prompt',
+ sessionId: 'session-auto-v2-bad-tier',
+ modelConfiguration: { tier: 'fast' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'balanced' });
+ });
+
+ it('re-routes the conversation when the tier changes', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ const chatRequest = {
+ location: ChatLocation.Panel,
+ prompt: 'test prompt',
+ sessionId: 'session-auto-v2-tier-change',
+ modelConfiguration: { tier: 'eco' },
+ } as unknown as ChatRequest;
+
+ await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ await automodeService.resolveAutoModeEndpoint({ ...chatRequest, prompt: 'second turn' } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ await automodeService.resolveAutoModeEndpoint({ ...chatRequest, prompt: 'third turn', modelConfiguration: { tier: 'max' } } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const tiers = (mockCAPIClientService.makeRequest as ReturnType).mock.calls
+ .filter(c => c[1]?.type === RequestType.Auto)
+ .map(c => JSON.parse(c[0].body).tier);
+ expect(tiers).toEqual(['eco', 'max']);
+ });
+
+ it('lets the tier override win over the picker and the inline chat pin', async () => {
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ enableAutoV2WithTierOverride('eco');
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'panel turn',
+ sessionId: 'session-override-panel',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Editor,
+ prompt: 'inline turn',
+ sessionId: 'session-override-inline',
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const tiers = (mockCAPIClientService.makeRequest as ReturnType).mock.calls
+ .filter(c => c[1]?.type === RequestType.Auto)
+ .map(c => JSON.parse(c[0].body).tier);
+ expect(tiers).toEqual(['eco', 'eco']);
+ });
+
+ // The override is an internal/eval knob, so unlike the picker it may target
+ // the profile inline chat reserves for itself.
+ it('allows the tier override to select the internal fast tier', async () => {
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ enableAutoV2WithTierOverride('fast');
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'panel turn',
+ sessionId: 'session-override-fast',
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'fast' });
+ });
+
+ it('ignores an unrecognized tier override', async () => {
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ enableAutoV2(new Map, unknown>([
+ [ConfigKey.Advanced.AutoModeTiersEnabled, true],
+ [ConfigKey.Advanced.AutoModeTierOverride, 'turbo'],
+ ]));
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'panel turn',
+ sessionId: 'session-override-bogus',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'max' });
+ });
+
+ it('withdraws tier support and announces it when /auto is gated off', async () => {
+ enableAutoV2WithTiers();
+ mockAuto({ error: 'not_found' }, 404);
+
+ automodeService = createService();
+ expect(automodeService.areAutoModeTiersSupported()).toBe(true);
+
+ let announced = 0;
+ const listener = automodeService.onDidChangeAutoModeTierSupport(() => announced++);
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'test prompt',
+ sessionId: 'session-auto-v2-404',
+ } as ChatRequest, [mockChatEndpoint]);
+ listener.dispose();
+
+ expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: false });
+ });
+
+ it('announces tier support when the setting changes', async () => {
+ enableAutoV2();
+
+ automodeService = createService();
+ expect(automodeService.areAutoModeTiersSupported()).toBe(false);
+
+ let announced = 0;
+ const listener = automodeService.onDidChangeAutoModeTierSupport(() => announced++);
+ await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, true);
+ // An unrelated change must not re-announce.
+ await configurationService.setConfig(ConfigKey.Advanced.AutoModeTierOverride, 'max');
+ listener.dispose();
+
+ expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: true });
+ });
+
+ it('does not reuse a cached endpoint from a different tier when /auto fails', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ const chatRequest = {
+ location: ChatLocation.Panel,
+ prompt: 'first turn',
+ sessionId: 'session-auto-v2-tier-error',
+ modelConfiguration: { tier: 'eco' },
+ } as unknown as ChatRequest;
+ const first = await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ expect(first.model).toBe('gpt-4o');
+
+ // The tier changes and the re-route fails: the eco endpoint must not be
+ // handed back as though it satisfied the new tier.
+ mockAuto({ error: 'server_error' }, 500);
+ const second = await automodeService.resolveAutoModeEndpoint({
+ ...chatRequest,
+ prompt: 'second turn',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ expect(second.model).toBe(mockChatEndpoint.model);
+ });
+
+ // `/auto` does not promise a new session token when the tier changes, so
+ // the endpoint (which bakes in the discount) cannot be reused across tiers.
+ it('rebuilds the endpoint when the tier changes but the session token does not', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ const autoResponse = (discount: number) => ({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ discounted_costs: { 'gpt-4o': discount },
+ });
+ mockAuto(autoResponse(0.2));
+
+ automodeService = createService();
+ const chatRequest = {
+ location: ChatLocation.Panel,
+ prompt: 'first turn',
+ sessionId: 'session-auto-v2-tier-discount',
+ modelConfiguration: { tier: 'eco' },
+ } as unknown as ChatRequest;
+ await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ mockAuto(autoResponse(0.9));
+ await automodeService.resolveAutoModeEndpoint({
+ ...chatRequest,
+ prompt: 'second turn',
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const discounts = (mockInstantiationService.createInstance as ReturnType).mock.calls.map(c => c[3]);
+ expect(discounts).toEqual([0.2, 0.9]);
+ });
+
+ it('does not evict an unrelated session when a cached conversation is rerouted', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+ const autoCallCount = () => (mockCAPIClientService.makeRequest as ReturnType).mock.calls.filter(c => c[1]?.type === RequestType.Auto).length;
+
+ automodeService = createService();
+ const route = (sessionId: string, prompt: string, tier?: string) => automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt,
+ sessionId,
+ modelConfiguration: tier ? { tier } : undefined,
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ // Fill the cache to AUTO_V2_CACHE_MAX_ENTRIES, then reroute the newest
+ // conversation: replacing its entry needs no room, so the oldest entry
+ // must still answer from cache.
+ for (let i = 0; i < 50; i++) {
+ await route(`session-${i}`, `turn ${i}`);
+ }
+ await route('session-49', 'retiered turn', 'max');
+
+ const callsBefore = autoCallCount();
+ await route('session-0', 'follow up');
+
+ expect(autoCallCount()).toBe(callsBefore);
+ });
+
+ it('keeps inline requests from overwriting the discount shown in the picker', async () => {
+ enableAutoV2WithTiers();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ discounted_costs: { 'gpt-4o': 0.2 },
+ });
+
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'panel turn',
+ sessionId: 'session-discount-panel',
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ discounted_costs: { 'gpt-4o': 0.9 },
+ });
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Editor,
+ prompt: 'inline turn',
+ sessionId: 'session-discount-inline',
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ expect(await automodeService.getAutoPickerMetadata()).toEqual({ discountRange: { low: 0.2, high: 0.2 } });
+ });
+
+ // Tiers are experiment-gated, so until the experiment reaches a user the
+ // request must look exactly as it did before tiers existed.
+ it('omits the tier and hides the picker while tiers are disabled', async () => {
+ enableAutoV2();
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ automodeService = createService();
+ for (const location of [ChatLocation.Panel, ChatLocation.Editor]) {
+ await automodeService.resolveAutoModeEndpoint({
+ location,
+ prompt: 'test prompt',
+ sessionId: `session-tiers-off-${location}`,
+ modelConfiguration: { tier: 'max' },
+ } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+ }
+
+ const bodies = (mockCAPIClientService.makeRequest as ReturnType).mock.calls
+ .filter(c => c[1]?.type === RequestType.Auto)
+ .map(c => JSON.parse(c[0].body));
+ expect({ bodies, supported: automodeService.areAutoModeTiersSupported() }).toEqual({
+ bodies: [
+ { prompt: 'test prompt' },
+ { prompt: 'test prompt' },
+ ],
+ supported: false,
+ });
+ });
+
+ // Evals need to exercise tiers before the experiment reaches them.
+ it('honors the tier override while tiers are disabled', async () => {
+ const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
+ mockAuto({
+ session_token: 'auto-v2-token',
+ expires_at: Math.floor(Date.now() / 1000) + 86400,
+ selected_model: { id: 'gpt-4o' },
+ });
+
+ enableAutoV2WithTierOverride('max');
+ automodeService = createService();
+ await automodeService.resolveAutoModeEndpoint({
+ location: ChatLocation.Panel,
+ prompt: 'panel turn',
+ sessionId: 'session-override-tiers-off',
+ } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
+
+ const autoCall = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.find(c => c[1]?.type === RequestType.Auto);
+ expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'max' });
});
it('resolves the picker endpoint without touching the legacy session under V2', async () => {
@@ -1773,6 +2195,22 @@ describe('AutomodeService', () => {
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'MODEL_PICKER_DISCOUNT_RESOLUTION - REPLACE ME' });
});
+ it('withdraws the tier picker when the discount probe is gated with a 404', async () => {
+ enableAutoV2WithTiers();
+ mockAuto({ error: 'not_found' }, 404);
+ const gpt4oMiniEndpoint = createEndpoint('gpt-4o-mini', 'OpenAI');
+
+ automodeService = createService();
+ const endpoint = await automodeService.resolveAutoModePickerEndpoint([gpt4oMiniEndpoint]);
+
+ const requestTypes = (mockCAPIClientService.makeRequest as ReturnType).mock.calls.map(c => c[1]?.type);
+ expect({
+ model: endpoint.model,
+ tiersSupported: automodeService.areAutoModeTiersSupported(),
+ usedLegacySession: requestTypes.includes(RequestType.AutoModels),
+ }).toEqual({ model: 'gpt-4o-mini', tiersSupported: false, usedLegacySession: true });
+ });
+
it('probes at most once even across concurrent picker refreshes', async () => {
enableAutoV2();
mockAuto({
diff --git a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts
index b53c19f653a..9bb8f29feed 100644
--- a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts
+++ b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts
@@ -162,14 +162,14 @@ export class SurveyService implements ISurveyService {
private async promptSurvey(surveyType: 'churn' | 'usage'): Promise {
const usage = await this.getUsageData();
- const source = this.lastSource || '';
+ const source = surveyType === 'churn' ? 'churn' : this.lastSource || '';
const language = this.lastLanguageId || '';
const firstSeenInDays = Math.floor((Date.now() - usage.firstActive) / (1000 * 60 * 60 * 24));
/* __GDPR__
"survey.show" : {
"owner": "digitarald",
"comment": "Measures survey notification result",
- "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last used feature before the survey." },
+ "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The feature or attribution category associated with the survey." },
"language": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last used editor language before the survey." },
"activeDays": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of days the user has used the extension." },
"firstActive": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of days since the user first used the extension." },
diff --git a/extensions/copilot/test/base/simulationOptions.spec.ts b/extensions/copilot/test/base/simulationOptions.spec.ts
new file mode 100644
index 00000000000..2f4c9d44626
--- /dev/null
+++ b/extensions/copilot/test/base/simulationOptions.spec.ts
@@ -0,0 +1,34 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT, SimulationOptions } from './simulationOptions';
+
+describe('SimulationOptions nes-datagen', () => {
+ it('parses the workspace recording oracle edit limit', () => {
+ const defaults = SimulationOptions.fromArray(['node', 'simulate', 'nes-datagen', '--input', 'recording.jsonl']);
+ const configured = SimulationOptions.fromArray(['node', 'simulate', 'nes-datagen', '--input', 'recording.jsonl', '--max-oracle-edits', '3']);
+
+ expect({
+ defaultValue: defaults.nesDatagen?.maxOracleEdits,
+ configuredValue: configured.nesDatagen?.maxOracleEdits,
+ }).toEqual({
+ defaultValue: DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT,
+ configuredValue: 3,
+ });
+ });
+
+ it('rejects a non-positive workspace recording oracle edit limit', () => {
+ expect(() => SimulationOptions.fromArray([
+ 'node',
+ 'simulate',
+ 'nes-datagen',
+ '--input',
+ 'recording.jsonl',
+ '--max-oracle-edits',
+ '0',
+ ])).toThrow('--max-oracle-edits must be a positive integer');
+ });
+});
diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts
index 01dafbd0836..f4b9b330d29 100644
--- a/extensions/copilot/test/base/simulationOptions.ts
+++ b/extensions/copilot/test/base/simulationOptions.ts
@@ -29,6 +29,7 @@ export enum NesDatagenInputFormat {
}
export const DEFAULT_WORKSPACE_RECORDING_SAMPLE_CAP = 100;
+export const DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT = 10;
/**
* How to choose the pivot in a continuous recording (only meaningful when
@@ -62,6 +63,8 @@ export type NesDatagen = {
readonly sameFileJumpMinBelow: number;
/** Maximum number of samples selected from one raw workspace recording. */
readonly maxSamplesPerRecording?: number;
+ /** Maximum number of composed, non-touching oracle edits in one sample. */
+ readonly maxOracleEdits?: number;
/** Whether to emit scoredEdits viewer files for generated samples. */
readonly generateScoredEdits: boolean;
/** Internal worker-only directory for staging scoredEdits files. */
@@ -247,6 +250,11 @@ export class SimulationOptions {
'--max-samples-per-recording',
DEFAULT_WORKSPACE_RECORDING_SAMPLE_CAP,
),
+ maxOracleEdits: SimulationOptions.validatePositiveInteger(
+ argv['max-oracle-edits'],
+ '--max-oracle-edits',
+ DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT,
+ ),
generateScoredEdits: boolean(argv['generate-scored-edits'], false),
scoredEditsOutputDirectory: argv['scored-edits-output-directory'],
workspacePivotOperationIndices: SimulationOptions.parseWorkspacePivotOperationIndices(argv['workspace-pivot-operation-indices']),
@@ -339,6 +347,7 @@ export class SimulationOptions {
` random → pick a single eligible pivot uniformly at random`,
` --seed Integer seed for the continuous pivot RNG (default: random, logged for reproducibility)`,
` --max-samples-per-recording Maximum samples selected from a workspace recording (default: 100)`,
+ ` --max-oracle-edits Maximum composed, non-touching oracle edits per sample (default: 10)`,
` --generate-scored-edits Generate .scoredEdits.w.json files beside the output JSONL`,
` Requires --sample-task=xtab`,
` --sample-task Which target to generate (default: xtab)`,
diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts
index 816c6ee6cc8..9483c45a2d6 100644
--- a/extensions/copilot/test/e2e/cli.stest.ts
+++ b/extensions/copilot/test/e2e/cli.stest.ts
@@ -131,9 +131,8 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
}
class TestCopilotCLISessionService extends CopilotCLISessionService {
- override async monitorSessionFiles() {
- // Override to do nothing in tests
- }
+ protected override monitorSessionFiles(): void { }
+
protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspace: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }) {
const sessionOptions = await super.createSessionsOptions({ ...options, agent: undefined });
const mutableOptions = sessionOptions as SessionOptions;
diff --git a/extensions/copilot/test/pipeline/alternativeAction/processor.ts b/extensions/copilot/test/pipeline/alternativeAction/processor.ts
index 09b4461b5f4..29e7edacded 100644
--- a/extensions/copilot/test/pipeline/alternativeAction/processor.ts
+++ b/extensions/copilot/test/pipeline/alternativeAction/processor.ts
@@ -3,11 +3,11 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { Edits } from '../../../src/platform/inlineEdits/common/dataTypes/edit';
-import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog';
-import { StringEdit, StringReplacement } from '../../../src/util/vs/editor/common/core/edits/stringEdit';
-import { OffsetRange } from '../../../src/util/vs/editor/common/core/ranges/offsetRange';
-import { ISerializedEdit } from '../logRecordingTypes';
+import { deserializeStringEdit } from '../../../src/platform/inlineEdits/common/dataTypes/editUtils';
+import { type ISerializedEdit, LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog';
+import { StringText } from '../../../src/util/vs/editor/common/core/text/abstractText';
+import { DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT } from '../../base/simulationOptions';
+import { composeAndLimitSerializedEdits, doesSerializedEditContinueOracle, ORACLE_CURSOR_CONTINUATION_LINE_GAP, ORACLE_CURSOR_SUPPRESSION_MS, ORACLE_EDIT_IDLE_MS } from '../oracleEdits';
import { IStringReplacement, NextUserEdit, Recording, Scoring, SuggestedEdit } from './types';
import { binarySearch, log } from './util';
@@ -97,6 +97,7 @@ export namespace Processor {
requestTime: number,
proposedEdits: IStringReplacement[],
isAccepted: boolean,
+ maxOracleEdits = DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT,
): Scoring.t | undefined {
const processedRecording = splitRecordingAtRequestTime(entries, requestTime);
@@ -111,15 +112,23 @@ export namespace Processor {
return undefined;
}
- return createScoringFromSplit(split, proposedEdits, isAccepted);
+ return createScoringFromSplit(split, proposedEdits, isAccepted, undefined, maxOracleEdits);
}
export function createScoringFromSplit(
split: ISplitRecording,
proposedEdits: IStringReplacement[],
isAccepted: boolean,
+ oracleEdits?: ISerializedEdit,
+ maxOracleEdits = DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT,
): Scoring.t {
- const nextUserEdit = getNextUserEdit(split.currentFile, split.recordingPriorToRequest, split.recordingAfterRequest);
+ const nextUserEdit: NextUserEdit.t = oracleEdits === undefined
+ ? getNextUserEdit(split.currentFile, split.recordingPriorToRequest, split.recordingAfterRequest, maxOracleEdits)
+ : {
+ edit: oracleEdits,
+ relativePath: split.currentFile.relativePath,
+ originalOpIdx: split.recordingPriorToRequest.length - 1,
+ };
const reconstructedRecording: Recording.t = {
log: split.recordingPriorToRequest,
@@ -193,29 +202,156 @@ export namespace Processor {
return fileId;
}
- function getNextUserEdit(currentFile: { id: number; relativePath: string }, recordingBeforeRequest: LogEntry[], recordingAfterRequest: LogEntry[]): NextUserEdit.t {
-
- const N_EDITS_LIMIT = 10;
-
+ function getNextUserEdit(
+ currentFile: { id: number; relativePath: string },
+ recordingBeforeRequest: LogEntry[],
+ recordingAfterRequest: LogEntry[],
+ maxOracleEdits: number,
+ ): NextUserEdit.t {
+ const initialState = getDocumentStateAtRequest(recordingBeforeRequest, currentFile.id);
+ let content = initialState.content;
+ let lastSelectionLine = initialState.selectionLine;
+ let lastEditTime: number | undefined;
+ let lastEditLineRange: ILineRange | undefined;
+ let hasPendingCursorBoundary = false;
const serializedEdits: ISerializedEdit[] = [];
+
for (const entry of recordingAfterRequest) {
- if (entry.kind === 'changed' && 'id' in entry && entry.id === currentFile.id) {
- serializedEdits.push(entry.edit);
+ if (entry.kind === 'selectionChanged' && entry.id === currentFile.id && entry.selection.length > 0 && content !== undefined) {
+ const selectionLine = getOffsetLine(content, entry.selection[0][0]);
+ const followsEdit = lastEditTime !== undefined
+ && entry.time - lastEditTime >= 0
+ && entry.time - lastEditTime <= ORACLE_CURSOR_SUPPRESSION_MS;
+ if (lastSelectionLine !== undefined && selectionLine !== lastSelectionLine && !followsEdit) {
+ hasPendingCursorBoundary = true;
+ }
+ lastSelectionLine = selectionLine;
+ continue;
}
- if (serializedEdits.length > N_EDITS_LIMIT) {
- break;
+
+ if (entry.kind === 'setContent' || entry.kind === 'restoreContent') {
+ if (entry.id === currentFile.id || serializedEdits.length > 0) {
+ break;
+ }
+ continue;
}
+ if (entry.kind !== 'changed') {
+ continue;
+ }
+ if (entry.id !== currentFile.id) {
+ if (serializedEdits.length > 0) {
+ break;
+ }
+ continue;
+ }
+
+ const edit = deserializeStringEdit(entry.edit);
+ const nextContent = content === undefined ? undefined : edit.apply(content);
+ if (content !== undefined && nextContent === content) {
+ continue;
+ }
+ const editLineRange = content === undefined ? undefined : getEditLineRange(content, edit);
+ if (serializedEdits.length > 0 && lastEditTime !== undefined) {
+ const delta = entry.time - lastEditTime;
+ const crossesIdleBoundary = delta <= 0 || delta >= ORACLE_EDIT_IDLE_MS;
+ const crossesCursorBoundary = hasPendingCursorBoundary
+ && (
+ delta <= 0
+ || delta >= ORACLE_EDIT_IDLE_MS
+ || lastEditLineRange === undefined
+ || editLineRange === undefined
+ || !areLineRangesWithinGap(lastEditLineRange, editLineRange, ORACLE_CURSOR_CONTINUATION_LINE_GAP)
+ );
+ if (crossesIdleBoundary || crossesCursorBoundary) {
+ if (doesSerializedEditContinueOracle(serializedEdits, entry.edit)) {
+ return createNextUserEdit(currentFile, recordingBeforeRequest, []);
+ }
+ break;
+ }
+ }
+
+ serializedEdits.push(entry.edit);
+ content = nextContent;
+ lastEditTime = entry.time;
+ lastEditLineRange = editLineRange;
+ hasPendingCursorBoundary = false;
}
- const edits = new Edits(
- StringEdit,
- serializedEdits.map(se => new StringEdit(se.map(r => new StringReplacement(new OffsetRange(r[0], r[1]), r[2]))))
+ return createNextUserEdit(
+ currentFile,
+ recordingBeforeRequest,
+ composeAndLimitSerializedEdits(serializedEdits, maxOracleEdits),
);
+ }
+ function createNextUserEdit(
+ currentFile: { id: number; relativePath: string },
+ recordingBeforeRequest: LogEntry[],
+ edit: ISerializedEdit,
+ ): NextUserEdit.t {
return {
- edit: edits.compose().replacements.map(r => [r.replaceRange.start, r.replaceRange.endExclusive, r.newText] as const),
+ edit,
relativePath: currentFile.relativePath,
originalOpIdx: recordingBeforeRequest.length - 1
};
}
+
+ interface ILineRange {
+ readonly startLine: number;
+ readonly endLine: number;
+ }
+
+ function getDocumentStateAtRequest(
+ recording: readonly LogEntry[],
+ documentId: number,
+ ): { content: string | undefined; selectionLine: number | undefined } {
+ let content: string | undefined;
+ let selectionLine: number | undefined;
+ const storedContent = new Map();
+ for (const entry of recording) {
+ if (!('id' in entry) || entry.id !== documentId) {
+ continue;
+ }
+ if (entry.kind === 'setContent') {
+ content = entry.content;
+ } else if (entry.kind === 'storeContent' && content !== undefined) {
+ storedContent.set(entry.contentId, content);
+ } else if (entry.kind === 'restoreContent') {
+ content = storedContent.get(entry.contentId);
+ } else if (entry.kind === 'changed' && content !== undefined) {
+ content = deserializeStringEdit(entry.edit).apply(content);
+ } else if (entry.kind === 'selectionChanged' && entry.selection.length > 0 && content !== undefined) {
+ selectionLine = getOffsetLine(content, entry.selection[0][0]);
+ }
+ }
+ return { content, selectionLine };
+ }
+
+ function getEditLineRange(content: string, edit: ReturnType): ILineRange | undefined {
+ if (edit.replacements.length === 0) {
+ return undefined;
+ }
+ const transformer = new StringText(content).getTransformer();
+ let startLine = Number.POSITIVE_INFINITY;
+ let endLine = Number.NEGATIVE_INFINITY;
+ for (const replacement of edit.replacements) {
+ startLine = Math.min(startLine, transformer.getPosition(replacement.replaceRange.start).lineNumber - 1);
+ endLine = Math.max(endLine, transformer.getPosition(replacement.replaceRange.endExclusive).lineNumber - 1);
+ }
+ return { startLine, endLine };
+ }
+
+ function getOffsetLine(content: string, offset: number): number {
+ return new StringText(content).getTransformer().getPosition(Math.min(offset, content.length)).lineNumber - 1;
+ }
+
+ function areLineRangesWithinGap(first: ILineRange, second: ILineRange, maxLineGap: number): boolean {
+ if (first.endLine < second.startLine) {
+ return second.startLine - first.endLine - 1 <= maxLineGap;
+ }
+ if (second.endLine < first.startLine) {
+ return first.startLine - second.endLine - 1 <= maxLineGap;
+ }
+ return true;
+ }
}
diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts
index b9f0746a14a..82c42f4fa7b 100644
--- a/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts
+++ b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts
@@ -29,6 +29,17 @@ function record(): IContinuousRecord {
return { originalRowIndex: 0, value: { entries, entriesSize: 100, ...META } };
}
+function cursorContinuationRecord(selectionOffset: number, editOffset: number): IContinuousRecord {
+ const cursorEntries: LogEntry[] = [
+ ...entries.slice(0, 4),
+ { kind: 'changed', id: 0, time: 1004, edit: [[175, 175, 'Z']], v: 1 },
+ { kind: 'selectionChanged', id: 0, time: 1006, selection: [[175, 175]] },
+ { kind: 'selectionChanged', id: 0, time: 1300, selection: [[selectionOffset, selectionOffset]] },
+ { kind: 'changed', id: 0, time: 1400, edit: [[editOffset, editOffset, 'Q']], v: 2 },
+ ];
+ return { originalRowIndex: 0, value: { entries: cursorEntries, entriesSize: 100, ...META } };
+}
+
describe('processContinuousRecord', () => {
it('synthesizes an oracle-only row and resolves language from the active file', () => {
const result = processContinuousRecord(record(), 1002);
@@ -43,6 +54,39 @@ describe('processContinuousRecord', () => {
const empty: IContinuousRecord = { originalRowIndex: 0, value: { entries: [], entriesSize: 0, ...META } };
expect(processContinuousRecord(empty, 0).isError()).toBe(true);
});
+
+ it('applies the composed oracle edit limit', () => {
+ const result = processContinuousRecord(record(), 1002, 1);
+ expect(result.isOk()).toBe(true);
+ if (result.isError()) { return; }
+ try {
+ expect(result.val.nextUserEdit.edit).toHaveLength(1);
+ } finally {
+ result.val.replayer.dispose();
+ }
+ });
+
+ it('continues across a nearby cursor move', () => {
+ const result = processContinuousRecord(cursorContinuationRecord(168, 168), 1002);
+ expect(result.isOk()).toBe(true);
+ if (result.isError()) { return; }
+ try {
+ expect(result.val.nextUserEdit.edit).toHaveLength(2);
+ } finally {
+ result.val.replayer.dispose();
+ }
+ });
+
+ it('stops before an edit after a distant cursor move', () => {
+ const result = processContinuousRecord(cursorContinuationRecord(7, 7), 1002);
+ expect(result.isOk()).toBe(true);
+ if (result.isError()) { return; }
+ try {
+ expect(result.val.nextUserEdit.edit).toEqual([[175, 175, 'Z']]);
+ } finally {
+ result.val.replayer.dispose();
+ }
+ });
});
describe('processContinuousRecords', () => {
diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.ts
index a44541bbfe3..ea28d5d398a 100644
--- a/extensions/copilot/test/pipeline/continuous/processContinuous.ts
+++ b/extensions/copilot/test/pipeline/continuous/processContinuous.ts
@@ -65,15 +65,15 @@ function synthesizeRow(record: IContinuousRecord, entries: LogEntry[], pivotTime
* (e.g. a malformed recorded edit) is caught and returned as an error `Result`,
* so one bad record can't abort a whole batch (see {@link processContinuousRecords}).
*/
-export function processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result {
+export function processContinuousRecord(record: IContinuousRecord, pivotTime: number, maxOracleEdits?: number): Result {
try {
- return _processContinuousRecord(record, pivotTime);
+ return _processContinuousRecord(record, pivotTime, maxOracleEdits);
} catch (e: unknown) {
return Result.error(ErrorUtils.fromUnknown(e));
}
}
-function _processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result {
+function _processContinuousRecord(record: IContinuousRecord, pivotTime: number, maxOracleEdits: number | undefined): Result {
const entries = record.value.entries;
if (!entries || entries.length === 0) {
return Result.fromString('Continuous recording has no entries');
@@ -85,6 +85,7 @@ function _processContinuousRecord(record: IContinuousRecord, pivotTime: number):
requestTime: pivotTime,
proposedEdits: [],
isAccepted: false,
+ maxOracleEdits,
});
if (result.isError()) {
return result;
@@ -118,6 +119,7 @@ export function processContinuousRecords(
strategy: PivotStrategy,
baseSeed: number,
rowOffset: number,
+ maxOracleEdits?: number,
): {
processed: IProcessedRow[];
errors: WithRowIndex[];
@@ -149,7 +151,7 @@ export function processContinuousRecords(
// threaded through those maps, otherwise rows sharing a record index
// would overwrite each other.
for (const pivotTime of pivots) {
- const result = processContinuousRecord(record, pivotTime);
+ const result = processContinuousRecord(record, pivotTime, maxOracleEdits);
if (result.isError()) {
errors.push({ originalRowIndex: record.originalRowIndex, value: result.err });
} else {
diff --git a/extensions/copilot/test/pipeline/oracleEdits.ts b/extensions/copilot/test/pipeline/oracleEdits.ts
new file mode 100644
index 00000000000..7cfc10628ef
--- /dev/null
+++ b/extensions/copilot/test/pipeline/oracleEdits.ts
@@ -0,0 +1,32 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Edits } from '../../src/platform/inlineEdits/common/dataTypes/edit';
+import { deserializeStringEdit, serializeStringEdit } from '../../src/platform/inlineEdits/common/dataTypes/editUtils';
+import type { ISerializedEdit } from '../../src/platform/workspaceRecorder/common/workspaceLog';
+import { StringEdit } from '../../src/util/vs/editor/common/core/edits/stringEdit';
+
+export const ORACLE_EDIT_IDLE_MS = 5 * 1000;
+export const ORACLE_CURSOR_SUPPRESSION_MS = 200;
+export const ORACLE_CURSOR_CONTINUATION_LINE_GAP = 3;
+
+export function composeSerializedEdits(edits: readonly ISerializedEdit[]): ISerializedEdit {
+ return serializeStringEdit(new Edits(StringEdit, edits.map(deserializeStringEdit)).compose());
+}
+
+export function composeAndLimitSerializedEdits(edits: readonly ISerializedEdit[], maxEdits: number): ISerializedEdit {
+ return composeSerializedEdits(edits).slice(0, maxEdits);
+}
+
+export function doesSerializedEditContinueOracle(
+ oracleEdits: readonly ISerializedEdit[],
+ nextEdit: ISerializedEdit,
+): boolean {
+ const current = composeSerializedEdits(oracleEdits);
+ const combined = composeSerializedEdits([...oracleEdits, nextEdit]);
+ return current.some(edit => !combined.some(candidate =>
+ candidate[0] === edit[0] && candidate[1] === edit[1] && candidate[2] === edit[2]
+ ));
+}
diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts
index 2925de1e4aa..ef9e2056a6b 100644
--- a/extensions/copilot/test/pipeline/pipeline.ts
+++ b/extensions/copilot/test/pipeline/pipeline.ts
@@ -14,7 +14,7 @@ import { Limiter } from '../../src/util/vs/base/common/async';
import { OffsetRange } from '../../src/util/vs/editor/common/core/ranges/offsetRange';
import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText';
import { applyConfigFile, loadConfigFile } from '../base/simulationContext';
-import { DEFAULT_WORKSPACE_RECORDING_SAMPLE_CAP, NesDatagen, NesDatagenInputFormat, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions';
+import { DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT, DEFAULT_WORKSPACE_RECORDING_SAMPLE_CAP, NesDatagen, NesDatagenInputFormat, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions';
import { loadAndParseContinuousInput } from './continuous/continuousRecord';
import { processContinuousRecords } from './continuous/processContinuous';
import { detectCrossFileJump, detectSameFileJump } from './cursorJump/detectJump';
@@ -49,6 +49,10 @@ function getWorkspaceRecordingSampleCap(options: NesDatagen): number {
return options.maxSamplesPerRecording ?? DEFAULT_WORKSPACE_RECORDING_SAMPLE_CAP;
}
+function getOracleEditLimit(options: NesDatagen): number {
+ return options.maxOracleEdits ?? DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT;
+}
+
/**
* Apply the user-supplied config file and force-disable all interactive
* debounces / cache delays that don't make sense in batch mode. Both
@@ -114,7 +118,11 @@ async function loadAndProduceProcessedRows(nesDatagenOpts: NesDatagen, verbose:
if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.WorkspaceRecording) {
const recording = await loadWorkspaceRecording(inputPath);
- const selected = selectWorkspaceRecordingSamples(recording, getWorkspaceRecordingSampleCap(nesDatagenOpts));
+ const selected = selectWorkspaceRecordingSamples(
+ recording,
+ getWorkspaceRecordingSampleCap(nesDatagenOpts),
+ getOracleEditLimit(nesDatagenOpts),
+ );
const selectedByOperationIndex = new Map(selected.map(descriptor => [descriptor.pivotOperationIndex, descriptor]));
const descriptors = nesDatagenOpts.workspacePivotOperationIndices === undefined
? selected
@@ -142,6 +150,7 @@ async function loadAndProduceProcessedRows(nesDatagenOpts: NesDatagen, verbose:
nesDatagenOpts.pivotStrategy,
nesDatagenOpts.seed,
nesDatagenOpts.rowOffset,
+ getOracleEditLimit(nesDatagenOpts),
);
return {
recordCount: records.length,
@@ -153,7 +162,7 @@ async function loadAndProduceProcessedRows(nesDatagenOpts: NesDatagen, verbose:
}
const { rows, errors: parseErrors } = await loadAndParseInput(inputPath, verbose);
- const { processed, errors: replayErrors } = processAllRows(rows);
+ const { processed, errors: replayErrors } = processAllRows(rows, getOracleEditLimit(nesDatagenOpts));
const languageByRowIndex = new Map(rows.map(row => [row.originalRowIndex, row.activeDocumentLanguageId]));
return {
recordCount: rows.length,
@@ -766,6 +775,7 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise
'--seed', String(nesDatagenOpts.seed),
'--same-file-jump-min-above', String(nesDatagenOpts.sameFileJumpMinAbove),
'--same-file-jump-min-below', String(nesDatagenOpts.sameFileJumpMinBelow),
+ '--max-oracle-edits', String(getOracleEditLimit(nesDatagenOpts)),
'--worker',
];
if (nesDatagenOpts.generateScoredEdits) {
@@ -803,14 +813,15 @@ async function runWorkspaceRecordingPipelineParallel(opts: SimulationOptions): P
const verbose = !!opts.verbose;
const recording = await loadWorkspaceRecording(inputPath);
const maxSamples = getWorkspaceRecordingSampleCap(nesDatagenOpts);
- const descriptors = selectWorkspaceRecordingSamples(recording, maxSamples);
+ const maxOracleEdits = getOracleEditLimit(nesDatagenOpts);
+ const descriptors = selectWorkspaceRecordingSamples(recording, maxSamples, maxOracleEdits);
const totalSamples = descriptors.length;
const partitions = partitionWork(totalSamples, opts.parallelism);
const numWorkers = Math.max(1, partitions.length);
console.log(`\n=== Pipeline (parallel: ${numWorkers} workers) ===`);
console.log(` Input: ${inputPath} (${totalSamples} selected workspace-recording samples)`);
- console.log(` Input format: workspace-recording (max samples: ${maxSamples})`);
+ console.log(` Input format: workspace-recording (max samples: ${maxSamples}, max oracle edits: ${maxOracleEdits})`);
console.log('');
if (totalSamples === 0) {
@@ -839,6 +850,7 @@ async function runWorkspaceRecordingPipelineParallel(opts: SimulationOptions): P
'--same-file-jump-min-above', String(nesDatagenOpts.sameFileJumpMinAbove),
'--same-file-jump-min-below', String(nesDatagenOpts.sameFileJumpMinBelow),
'--max-samples-per-recording', String(maxSamples),
+ '--max-oracle-edits', String(maxOracleEdits),
'--workspace-pivot-operation-indices', pivotOperationIndices.join(','),
'--worker',
];
diff --git a/extensions/copilot/test/pipeline/replayRecording.spec.ts b/extensions/copilot/test/pipeline/replayRecording.spec.ts
index df94c4980b0..5c8b9cbe6d0 100644
--- a/extensions/copilot/test/pipeline/replayRecording.spec.ts
+++ b/extensions/copilot/test/pipeline/replayRecording.spec.ts
@@ -16,7 +16,7 @@ const doc = `const a = 1;\nconst b = 2;\n`;
* cleanly; overlapping replacements make replay throw, which is how we exercise
* the error path without any stubbing.
*/
-function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][]): IInputRow {
+function makeRowWithPostEntries(originalRowIndex: number, postRequestEntries: LogEntry[]): IInputRow {
const entries: LogEntry[] = [
{ kind: 'meta', data: { repoRootUri: 'file:///ws' } },
{ kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/a.ts' },
@@ -24,7 +24,7 @@ function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][
// Pre-pivot no-op edit so the replayer has a `lastId`.
{ kind: 'changed', id: 0, time: 1002, edit: [[0, 0, '']], v: 1 },
// --- requestTime 1003 splits here; the rest is the oracle ---
- { kind: 'changed', id: 0, time: 1004, edit: oracleEdit, v: 2 },
+ ...postRequestEntries,
];
return {
originalRowIndex,
@@ -44,6 +44,12 @@ function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][
};
}
+function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][]): IInputRow {
+ return makeRowWithPostEntries(originalRowIndex, [
+ { kind: 'changed', id: 0, time: 1004, edit: oracleEdit, v: 2 },
+ ]);
+}
+
describe('processAllRows', () => {
it('labels replay errors with the row\'s originalRowIndex, not its filtered array position', () => {
// Earlier parse failures make `loadAndParseInput` hand back a *sparse*
@@ -66,4 +72,51 @@ describe('processAllRows', () => {
processed.forEach(p => p.replayer.dispose());
}
});
+
+ it('composes touching operations before applying the oracle edit limit', () => {
+ const insertedText = 'abcdefghijkl';
+ const postRequestEntries: LogEntry[] = [...insertedText].map((text, index) => ({
+ kind: 'changed',
+ id: 0,
+ time: 1004 + index,
+ edit: [[doc.length + index, doc.length + index, text]],
+ v: index + 2,
+ }));
+ const { processed, errors } = processAllRows([makeRowWithPostEntries(0, postRequestEntries)], 1);
+ try {
+ expect({
+ errors,
+ nextUserEdit: processed[0]?.nextUserEdit,
+ }).toEqual({
+ errors: [],
+ nextUserEdit: {
+ edit: [[doc.length, doc.length, insertedText]],
+ relativePath: 'src/a.ts',
+ originalOpIdx: 3,
+ },
+ });
+ } finally {
+ processed.forEach(processedRow => processedRow.replayer.dispose());
+ }
+ });
+
+ it('stops the oracle before a content restore', () => {
+ const postRequestEntries: LogEntry[] = [
+ { kind: 'changed', id: 0, time: 1004, edit: [[6, 7, 'x']], v: 2 },
+ { kind: 'restoreContent', id: 0, time: 1005, contentId: 'saved', v: 3 },
+ { kind: 'changed', id: 0, time: 1006, edit: [[19, 20, 'y']], v: 4 },
+ ];
+ const { processed, errors } = processAllRows([makeRowWithPostEntries(0, postRequestEntries)]);
+ try {
+ expect({
+ errors,
+ nextUserEdit: processed[0]?.nextUserEdit.edit,
+ }).toEqual({
+ errors: [],
+ nextUserEdit: [[6, 7, 'x']],
+ });
+ } finally {
+ processed.forEach(processedRow => processedRow.replayer.dispose());
+ }
+ });
});
diff --git a/extensions/copilot/test/pipeline/replayRecording.ts b/extensions/copilot/test/pipeline/replayRecording.ts
index ed8287e3e7c..0170e69c9e8 100644
--- a/extensions/copilot/test/pipeline/replayRecording.ts
+++ b/extensions/copilot/test/pipeline/replayRecording.ts
@@ -6,7 +6,7 @@
import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../../src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer';
import { DocumentId } from '../../src/platform/inlineEdits/common/dataTypes/documentId';
import { IObservableDocument, MutableObservableWorkspace } from '../../src/platform/inlineEdits/common/observableWorkspace';
-import { LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog';
+import { type ISerializedEdit, LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog';
import { ErrorUtils } from '../../src/util/common/errors';
import { Result } from '../../src/util/common/result';
import { coalesce } from '../../src/util/vs/base/common/arrays';
@@ -72,11 +72,11 @@ export interface IProcessedRow {
export interface IWorkspaceRecordingSampleProvenance {
readonly sourceFormat: 'workspace-recording';
readonly recordingRevision: 4;
- readonly policyVersion: 1;
+ readonly policyVersion: 2;
readonly pivotKind: 'user-edit' | 'cursor-move';
readonly pivotOperationIndex: number;
readonly oracleOperationCount: number;
- readonly oracleStopReason: 'cursor-move' | 'generated-edit' | 'ambiguous-edit' | 'other-document-edit' | 'idle-gap' | 'edit-limit' | 'end-of-recording';
+ readonly oracleStopReason: 'cursor-move' | 'generated-edit' | 'ambiguous-edit' | 'other-document-edit' | 'idle-gap';
readonly contextTruncated: boolean;
}
@@ -110,15 +110,15 @@ export function parseSuggestedEdit(suggestedEditStr: string): [start: number, en
* Process a single input row: split recording at request time, replay
* the pre-request portion and extract the oracle edit.
*/
-export function processRow(row: IInputRow): Result {
+export function processRow(row: IInputRow, maxOracleEdits?: number): Result {
try {
- return _processRow(row);
+ return _processRow(row, maxOracleEdits);
} catch (e: unknown) {
return Result.error(ErrorUtils.fromUnknown(e));
}
}
-function _processRow(row: IInputRow): Result {
+function _processRow(row: IInputRow, maxOracleEdits: number | undefined): Result {
const proposedEdits = coalesce([parseSuggestedEdit(row.postProcessingOutcome.suggestedEdit)]);
const isAccepted = row.suggestionStatus === 'accepted';
@@ -135,6 +135,7 @@ function _processRow(row: IInputRow): Result {
requestTime: recording.requestTime,
proposedEdits,
isAccepted,
+ maxOracleEdits,
});
}
@@ -156,6 +157,8 @@ interface IProcessRecordingArgs {
readonly entries: LogEntry[];
readonly proposedEdits: IStringReplacement[];
readonly isAccepted: boolean;
+ readonly oracleEdits?: ISerializedEdit;
+ readonly maxOracleEdits?: number;
readonly workspaceRecording?: IWorkspaceRecordingSampleProvenance;
}
@@ -202,11 +205,13 @@ function _processRecordingAtSplit(
readonly row: IInputRow;
readonly proposedEdits: IStringReplacement[];
readonly isAccepted: boolean;
+ readonly oracleEdits?: ISerializedEdit;
+ readonly maxOracleEdits?: number;
readonly workspaceRecording?: IWorkspaceRecordingSampleProvenance;
},
split: Processor.ISplitRecording,
): Result {
- const scoring = Processor.createScoringFromSplit(split, args.proposedEdits, args.isAccepted);
+ const scoring = Processor.createScoringFromSplit(split, args.proposedEdits, args.isAccepted, args.oracleEdits, args.maxOracleEdits);
const recording = scoring.scoringContext.recording;
@@ -308,7 +313,7 @@ function _processRecordingAtSplit(
* Process all input rows.
* Each returned `IProcessedRow` holds a live replayer that must be disposed by the caller.
*/
-export function processAllRows(rows: readonly IInputRow[]): {
+export function processAllRows(rows: readonly IInputRow[], maxOracleEdits?: number): {
processed: IProcessedRow[];
errors: WithRowIndex[];
} {
@@ -317,7 +322,7 @@ export function processAllRows(rows: readonly IInputRow[]): {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
- const result = processRow(row);
+ const result = processRow(row, maxOracleEdits);
if (result.isError()) {
errors.push({ originalRowIndex: row.originalRowIndex, value: result.err });
} else {
diff --git a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts
index 4598f272585..489fd25ba0f 100644
--- a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts
+++ b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts
@@ -175,6 +175,27 @@ describe('nes-datagen pipeline e2e', () => {
]);
});
+ test('applies the configured oracle edit limit to alternative-action recordings', async () => {
+ const result = await runPipeline({
+ nesDatagen: {
+ input: inputPath,
+ output: outputPath,
+ rowOffset: 0,
+ workerMode: false,
+ generateScoredEdits: false,
+ sampleTask: NesDatagenSampleTask.Xtab,
+ sameFileJumpMinAbove: 5,
+ sameFileJumpMinBelow: 5,
+ inputFormat: NesDatagenInputFormat.AlternativeAction,
+ pivotStrategy: PivotStrategy.Random,
+ seed: 0,
+ maxOracleEdits: 1,
+ },
+ });
+
+ expect(result.samples.map(sample => sample.metadata.oracleEdits.length)).toEqual([1, 1]);
+ });
+
test('produces output samples for valid rows', () => {
// 2 valid rows (ts + py), 1 invalid row (missing recording)
expect(result.samples.length).toBe(2);
diff --git a/extensions/copilot/test/pipeline/test/workspaceRecordingPipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/workspaceRecordingPipeline.e2e.spec.ts
index f3f83a7a5c1..970b2908e5f 100644
--- a/extensions/copilot/test/pipeline/test/workspaceRecordingPipeline.e2e.spec.ts
+++ b/extensions/copilot/test/pipeline/test/workspaceRecordingPipeline.e2e.spec.ts
@@ -37,6 +37,7 @@ async function runRecording(
entries: readonly LogEntry[],
sampleTask: NesDatagenSampleTask,
generateScoredEdits = false,
+ maxOracleEdits = 10,
): Promise<{ samples: ISample[]; logs: string[]; scoredEdits: { fileName: string; value: Scoring.t }[] }> {
const inputPath = path.join(tmpDir, `input-${sampleTask}.workspaceRecording.jsonl`);
const outputPath = path.join(tmpDir, `output-${sampleTask}.jsonl`);
@@ -56,6 +57,7 @@ async function runRecording(
sameFileJumpMinAbove: 2,
sameFileJumpMinBelow: 5,
maxSamplesPerRecording: 100,
+ maxOracleEdits,
generateScoredEdits,
},
configFile: configPath,
@@ -111,6 +113,14 @@ describe('nes-datagen workspace recording pipeline', () => {
v: 3,
metadata: { source: 'cursor', kind: 'type', detailedSource: 'keyboard' },
},
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1005,
+ edit: [[0, 0, 'generated']],
+ v: 4,
+ metadata: { source: 'applyEdits' },
+ },
];
const { samples, logs, scoredEdits } = await runRecording(entries, NesDatagenSampleTask.Xtab, true);
@@ -139,11 +149,11 @@ describe('nes-datagen workspace recording pipeline', () => {
workspaceRecording: {
sourceFormat: 'workspace-recording',
recordingRevision: 4,
- policyVersion: 1,
+ policyVersion: 2,
pivotKind: 'user-edit',
pivotOperationIndex: 2,
oracleOperationCount: 1,
- oracleStopReason: 'end-of-recording',
+ oracleStopReason: 'generated-edit',
contextTruncated: false,
},
}],
@@ -165,6 +175,46 @@ describe('nes-datagen workspace recording pipeline', () => {
});
});
+ it('limits the composed oracle edits using the configured maximum', async () => {
+ const documentContent = 'const value = 1;\n';
+ const entries: LogEntry[] = [
+ header,
+ { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/value.ts' },
+ { kind: 'setContent', id: 0, time: 1000, content: documentContent, v: 1 },
+ { kind: 'selectionChanged', id: 0, time: 1001, selection: [[documentContent.length, documentContent.length]] },
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1002,
+ edit: [[documentContent.length, documentContent.length, 'p']],
+ v: 2,
+ metadata: { source: 'cursor', kind: 'type', detailedSource: 'keyboard' },
+ },
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1003,
+ edit: [[0, 0, 'a'], [6, 6, 'b'], [12, 12, 'c']],
+ v: 3,
+ metadata: { source: 'cursor', kind: 'type', detailedSource: 'keyboard' },
+ },
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1004,
+ edit: [[documentContent.length + 1, documentContent.length + 1, 'generated']],
+ v: 4,
+ metadata: { source: 'applyEdits' },
+ },
+ ];
+
+ const { samples, logs } = await runRecording(entries, NesDatagenSampleTask.Xtab, false, 2);
+ expect(samples.map(sample => sample.metadata.oracleEdits), logs.join('\n')).toEqual([[
+ [0, 0, 'a'],
+ [6, 6, 'b'],
+ ]]);
+ });
+
it('retains the first deliberate cursor boundary for cursor-task generation', async () => {
const documentContent = Array.from({ length: 30 }, (_, index) => `// A${String(index).padStart(2, '0')}`).join('\n') + '\n';
const cursorOffset = 7 * 2;
diff --git a/extensions/copilot/test/pipeline/workspaceRecording/processWorkspaceRecording.ts b/extensions/copilot/test/pipeline/workspaceRecording/processWorkspaceRecording.ts
index 6ae2be53009..4a99bdab669 100644
--- a/extensions/copilot/test/pipeline/workspaceRecording/processWorkspaceRecording.ts
+++ b/extensions/copilot/test/pipeline/workspaceRecording/processWorkspaceRecording.ts
@@ -50,6 +50,7 @@ export function processWorkspaceRecordingSample(
pivotEntryIndex: sample.pivotEntryIndex,
proposedEdits: [],
isAccepted: false,
+ oracleEdits: descriptor.oracleEdits,
workspaceRecording: sample.provenance,
});
if (result.isError()) {
diff --git a/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.spec.ts b/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.spec.ts
index c2b049413ec..73808cbdb27 100644
--- a/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.spec.ts
+++ b/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.spec.ts
@@ -14,7 +14,6 @@ import {
materializeWorkspaceRecordingSample,
selectWorkspaceRecordingSamples,
type IWorkspaceRecordingSampleDescriptor,
- WORKSPACE_RECORDING_ORACLE_EDIT_LIMIT,
} from './workspaceRecording';
const header: HeaderLogEntry = {
@@ -40,6 +39,17 @@ function userEdit(id: number, time: number, start: number, text: string, version
}
function generatedEdit(id: number, time: number, start: number, text: string, version: number): LogEntry {
+ return {
+ kind: 'changed',
+ id,
+ time,
+ edit: [[start, start, text]],
+ v: version,
+ metadata: { source: 'applyEdits' },
+ };
+}
+
+function acceptedEdit(id: number, time: number, start: number, text: string, version: number): LogEntry {
return {
kind: 'changed',
id,
@@ -119,6 +129,7 @@ describe('workspace recording pivot policy', () => {
userEdit(0, 1000, content.length, 'a', 2),
{ kind: 'selectionChanged', id: 0, time: 1000 + delta, selection: [[5, 5]] } satisfies LogEntry,
userEdit(0, 2000, content.length + 1, 'b', 3),
+ generatedEdit(0, 2100, 0, 'generated', 4),
];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
@@ -132,6 +143,7 @@ describe('workspace recording pivot policy', () => {
{ kind: 'selectionChanged', id: 0, time: 900, selection: [[5, 5]] } satisfies LogEntry,
userEdit(0, 1000, content.length, 'a', 2),
userEdit(0, 1100, content.length + 1, 'b', 3),
+ generatedEdit(0, 1200, 0, 'generated', 4),
];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
@@ -139,12 +151,36 @@ describe('workspace recording pivot policy', () => {
});
});
+ it('continues a nearby oracle after a cursor move', async () => {
+ const entries = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'a', 2),
+ userEdit(0, 1100, content.length + 1, 's', 3),
+ { kind: 'selectionChanged', id: 0, time: 1500, selection: [[content.indexOf('two'), content.indexOf('two')]] } satisfies LogEntry,
+ acceptedEdit(0, 2000, content.length + 2, 'et', 4),
+ generatedEdit(0, 2100, 0, 'generated', 5),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ const sample = selectWorkspaceRecordingSamples(recording, 100).find(sample => sample.pivotOperationIndex === 2);
+ expect({
+ oracleOperationCount: sample?.oracleOperationIndices.length,
+ oracleEdits: sample?.oracleEdits,
+ stopReason: sample?.oracleStopReason,
+ }).toEqual({
+ oracleOperationCount: 2,
+ oracleEdits: [[content.length + 1, content.length + 1, 'set']],
+ stopReason: 'generated-edit',
+ });
+ });
+ });
+
it('stops an oracle before a generated edit', async () => {
const entries = [
...documentPrefix(),
userEdit(0, 1000, content.length, 'a', 2),
userEdit(0, 1100, content.length + 1, 'b', 3),
- generatedEdit(0, 1200, content.length + 2, 'generated', 4),
+ generatedEdit(0, 1200, 0, 'generated', 4),
];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
@@ -159,31 +195,189 @@ describe('workspace recording pivot policy', () => {
});
});
- it('caps an oracle at ten change operations', async () => {
- const entries = [...documentPrefix()];
- let currentLength = content.length;
- for (let i = 0; i < WORKSPACE_RECORDING_ORACLE_EDIT_LIMIT + 2; i++) {
- entries.push(userEdit(0, 1000 + i * 100, currentLength, String(i % 10), i + 2));
- currentLength++;
- }
+ it('composes consecutive accepted completions with the user edit', async () => {
+ const entries = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'p', 2),
+ userEdit(0, 1100, content.length + 1, 'inter', 3),
+ acceptedEdit(0, 9000, content.length + 6, 'face Device', 4),
+ acceptedEdit(0, 18_000, content.length + 17, 'Option {', 5),
+ generatedEdit(0, 18_100, 0, 'generated', 6),
+ ];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
const first = selectWorkspaceRecordingSamples(recording, 100)[0];
expect({
oracleOperationCount: first.oracleOperationIndices.length,
+ oracleEdits: first.oracleEdits,
stopReason: first.oracleStopReason,
}).toEqual({
- oracleOperationCount: WORKSPACE_RECORDING_ORACLE_EDIT_LIMIT,
- stopReason: 'edit-limit',
+ oracleOperationCount: 3,
+ oracleEdits: [[content.length + 1, content.length + 1, 'interface DeviceOption {']],
+ stopReason: 'generated-edit',
});
});
});
+ it('ignores no-op generated edits while collecting the oracle', async () => {
+ const entries: LogEntry[] = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'p', 2),
+ userEdit(0, 1100, content.length + 1, 'inter', 3),
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1200,
+ edit: [[0, 1, 'z']],
+ v: 4,
+ metadata: { source: 'suggest' },
+ },
+ userEdit(0, 1300, content.length + 6, 'face', 5),
+ generatedEdit(0, 1400, 0, 'generated', 6),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ const first = selectWorkspaceRecordingSamples(recording, 100)[0];
+ expect({
+ oracleOperationCount: first.oracleOperationIndices.length,
+ oracleEdits: first.oracleEdits,
+ stopReason: first.oracleStopReason,
+ }).toEqual({
+ oracleOperationCount: 2,
+ oracleEdits: [[content.length + 1, content.length + 1, 'interface']],
+ stopReason: 'generated-edit',
+ });
+ });
+ });
+
+ it('omits an oracle continued by a touching generated edit', async () => {
+ const entries = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'a', 2),
+ userEdit(0, 1100, content.length + 1, 'b', 3),
+ generatedEdit(0, 1200, content.length + 2, 'generated', 4),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ expect(selectWorkspaceRecordingSamples(recording, 100)).toEqual([]);
+ });
+ });
+
+ it('omits an oracle continued by a touching edit after an idle gap', async () => {
+ const entries = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'a', 2),
+ userEdit(0, 1100, content.length + 1, 'b', 3),
+ userEdit(0, 6200, content.length + 2, 'c', 4),
+ generatedEdit(0, 6300, 0, 'generated', 5),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ expect(selectWorkspaceRecordingSamples(recording, 100)).toEqual([]);
+ });
+ });
+
+ it('composes touching change operations before limiting oracle edits', async () => {
+ const entries = [...documentPrefix()];
+ let currentLength = content.length;
+ entries.push(userEdit(0, 1000, currentLength, 'p', 2));
+ currentLength++;
+ for (let i = 0; i < 12; i++) {
+ entries.push(userEdit(0, 1100 + i * 100, currentLength, String(i % 10), i + 3));
+ currentLength++;
+ }
+ entries.push(generatedEdit(0, 2400, 0, 'generated', 15));
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ const first = selectWorkspaceRecordingSamples(recording, 100, 1)[0];
+ const processed = processWorkspaceRecordingSample(recording, first, 0);
+ try {
+ expect({
+ oracleOperationCount: first.oracleOperationIndices.length,
+ oracleEdits: first.oracleEdits,
+ processedOracleEdits: processed.isOk() ? processed.val.nextUserEdit.edit : undefined,
+ stopReason: first.oracleStopReason,
+ }).toEqual({
+ oracleOperationCount: 12,
+ oracleEdits: [[content.length + 1, content.length + 1, '012345678901']],
+ processedOracleEdits: [[content.length + 1, content.length + 1, '012345678901']],
+ stopReason: 'generated-edit',
+ });
+ } finally {
+ if (processed.isOk()) {
+ processed.val.replayer.dispose();
+ }
+ }
+ });
+ });
+
+ it('limits composed non-touching oracle edits', async () => {
+ const entries: LogEntry[] = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'p', 2),
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1100,
+ edit: [[0, 0, 'a'], [5, 5, 'b'], [10, 10, 'c']],
+ v: 3,
+ metadata: { source: 'cursor', kind: 'type', detailedSource: 'keyboard' },
+ },
+ generatedEdit(0, 1200, content.length + 1, 'generated', 4),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ expect(selectWorkspaceRecordingSamples(recording, 100, 2)[0].oracleEdits).toEqual([
+ [0, 0, 'a'],
+ [5, 5, 'b'],
+ ]);
+ });
+ });
+
+ it('omits samples whose oracle reaches the end of the recording', async () => {
+ const entries = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'a', 2),
+ userEdit(0, 1100, content.length + 1, 'b', 3),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ expect(selectWorkspaceRecordingSamples(recording, 100)).toEqual([]);
+ });
+ });
+
+ it('omits an oracle that composes to no edit', async () => {
+ const entries: LogEntry[] = [
+ ...documentPrefix(),
+ userEdit(0, 1000, content.length, 'p', 2),
+ userEdit(0, 1100, content.length + 1, 'x', 3),
+ {
+ kind: 'changed',
+ id: 0,
+ time: 1200,
+ edit: [[content.length + 1, content.length + 2, '']],
+ v: 4,
+ metadata: { source: 'cursor', kind: 'type', detailedSource: 'keyboard' },
+ },
+ generatedEdit(0, 1300, 0, 'generated', 5),
+ ];
+ await withRecording(entries, async recordingPath => {
+ const recording = await loadWorkspaceRecording(recordingPath);
+ expect(selectWorkspaceRecordingSamples(recording, 100).some(sample => sample.pivotOperationIndex === 2)).toBe(false);
+ });
+ });
+
it('evenly caps selected pivots deterministically', async () => {
const entries = [...documentPrefix()];
let currentLength = content.length;
+ let version = 2;
for (let i = 0; i < 103; i++) {
- entries.push(userEdit(0, 1000 + i * 100, currentLength, 'x', i + 2));
+ const time = 1000 + i * 300;
+ entries.push(userEdit(0, time, currentLength, 'x', version++));
+ currentLength++;
+ entries.push(userEdit(0, time + 100, currentLength, 'y', version++));
+ currentLength++;
+ entries.push(generatedEdit(0, time + 200, 0, 'g', version++));
currentLength++;
}
await withRecording(entries, async recordingPath => {
@@ -198,7 +392,7 @@ describe('workspace recording pivot policy', () => {
one: selectWorkspaceRecordingSamples(recording, 1).map(sample => sample.pivotOperationIndex),
none: selectWorkspaceRecordingSamples(recording, 0),
}).toEqual({
- all: 102,
+ all: 103,
capped: 100,
first: all[0].pivotOperationIndex,
last: all.at(-1)?.pivotOperationIndex,
@@ -240,6 +434,7 @@ describe('workspace recording materialization', () => {
{ kind: 'selectionChanged', id: 0, time: 400_050, selection: [[0, 0]] },
userEdit(0, 400_100, content.length, 'a', 3),
userEdit(0, 400_200, content.length + 1, 'b', 4),
+ generatedEdit(0, 400_300, 0, 'generated', 5),
];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
@@ -281,6 +476,7 @@ describe('workspace recording materialization', () => {
},
userEdit(0, 1000, content.length, 'a', 2),
userEdit(0, 1100, content.length + 1, 'b', 3),
+ generatedEdit(0, 1200, 0, 'generated', 4),
];
await withRecording(entries, async recordingPath => {
const recording = await loadWorkspaceRecording(recordingPath);
diff --git a/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.ts b/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.ts
index f8a3b3d324f..d253aeae8a3 100644
--- a/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.ts
+++ b/extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.ts
@@ -6,23 +6,23 @@
import { createHash } from 'crypto';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
+import { DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT } from '../../base/simulationOptions';
import { deserializeStringEdit, serializeStringEdit } from '../../../src/platform/inlineEdits/common/dataTypes/editUtils';
import { RecordingData, ResolvedRecording } from '../../../src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording';
import { OperationKind, type Operation } from '../../../src/platform/workspaceRecorder/common/resolvedRecording/operation';
import type { HeaderLogEntry, ISerializedEdit, ISerializedOffsetRange, LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog';
import { ErrorUtils } from '../../../src/util/common/errors';
import { StringText } from '../../../src/util/vs/editor/common/core/text/abstractText';
+import { composeAndLimitSerializedEdits, doesSerializedEditContinueOracle, ORACLE_CURSOR_CONTINUATION_LINE_GAP, ORACLE_CURSOR_SUPPRESSION_MS, ORACLE_EDIT_IDLE_MS } from '../oracleEdits';
import type { IWorkspaceRecordingSampleProvenance } from '../replayRecording';
const WORKSPACE_RECORDING_CONTEXT_WINDOW_MS = 5 * 60 * 1000;
-const WORKSPACE_RECORDING_CURSOR_SUPPRESSION_MS = 200;
-const WORKSPACE_RECORDING_ORACLE_IDLE_MS = 5 * 1000;
-export const WORKSPACE_RECORDING_ORACLE_EDIT_LIMIT = 10;
const WORKSPACE_RECORDING_SYNTHETIC_TIME_BASE = 3_000_000;
-type EditClassification = 'user' | 'generated' | 'ambiguous';
+type EditClassification = 'user' | 'accepted' | 'partially-accepted' | 'generated' | 'ambiguous';
type WorkspacePivotKind = IWorkspaceRecordingSampleProvenance['pivotKind'];
type WorkspaceOracleStopReason = IWorkspaceRecordingSampleProvenance['oracleStopReason'];
+type WorkspaceOracleCollectionStopReason = WorkspaceOracleStopReason | 'end-of-recording' | 'touching-boundary';
export interface IWorkspaceRecording {
readonly entries: LogEntry[];
@@ -34,6 +34,7 @@ export interface IWorkspaceRecordingSampleDescriptor {
readonly pivotOperationIndex: number;
readonly pivotKind: WorkspacePivotKind;
readonly oracleOperationIndices: readonly number[];
+ readonly oracleEdits: ISerializedEdit;
readonly cursorBoundaryOperationIndex: number | undefined;
readonly oracleStopReason: WorkspaceOracleStopReason;
}
@@ -55,8 +56,6 @@ const userCursorKinds = new Set([
]);
const generatedEditSources = new Set([
- 'inlineCompletionAccept',
- 'inlineCompletionPartialAccept',
'Chat.applyEdits',
'inlineChat.applyEdits',
'reloadFromDisk',
@@ -128,7 +127,12 @@ export async function loadWorkspaceRecording(inputPath: string): Promise | undefined): EditCl
const kind = metadata['kind'];
return typeof kind === 'string' && userCursorKinds.has(kind) ? 'user' : 'ambiguous';
}
- if (generatedEditSources.has(source)) {
- return 'generated';
+ return classifyNonCursorSource(source);
+}
+
+function classifyNonCursorSource(source: string): EditClassification {
+ if (source === 'inlineCompletionAccept') {
+ return 'accepted';
}
- return 'ambiguous';
+ if (source === 'inlineCompletionPartialAccept') {
+ return 'partially-accepted';
+ }
+ return generatedEditSources.has(source) ? 'generated' : 'ambiguous';
}
function collectLegacyClassifications(entries: readonly LogEntry[]): ReadonlyMap {
@@ -552,9 +568,7 @@ function collectLegacyClassifications(entries: readonly LogEntry[]): ReadonlyMap
if (typeof version !== 'number' || !Number.isInteger(version) || typeof source !== 'string') {
continue;
}
- const classification = source === 'cursor'
- ? 'user'
- : generatedEditSources.has(source) ? 'generated' : 'ambiguous';
+ const classification = source === 'cursor' ? 'user' : classifyNonCursorSource(source);
const key = documentVersionKey(entry.id, version);
const previous = result.get(key);
result.set(key, previous !== undefined && previous !== classification ? 'ambiguous' : classification);
@@ -585,7 +599,7 @@ function findDeliberateCursorOperations(recording: IWorkspaceRecording): Readonl
const lastEditTime = lastEditTimeByDocument.get(operation.documentId);
const delta = lastEditTime === undefined ? undefined : operation.time - lastEditTime;
- const followsSameDocumentEdit = delta !== undefined && delta >= 0 && delta <= WORKSPACE_RECORDING_CURSOR_SUPPRESSION_MS;
+ const followsSameDocumentEdit = delta !== undefined && delta >= 0 && delta <= ORACLE_CURSOR_SUPPRESSION_MS;
if (changedLocation && !followsSameDocumentEdit) {
result.add(operation.operationIdx);
}
@@ -603,7 +617,7 @@ function collectOracle(
): {
operationIndices: number[];
cursorBoundaryOperationIndex: number | undefined;
- stopReason: WorkspaceOracleStopReason;
+ stopReason: WorkspaceOracleCollectionStopReason;
} {
const operationIndices: number[] = [];
let previousEditTime = pivot.time;
@@ -611,6 +625,40 @@ function collectOracle(
for (let i = pivot.operationIdx + 1; i < recording.resolved.operations.length; i++) {
const operation = recording.resolved.operations[i];
if (deliberateCursorOperations.has(i)) {
+ const nextChangeOperationIndex = findNextDocumentChangeOperationIndex(recording, i + 1, pivot.documentId);
+ if (
+ operationIndices.length > 0
+ && nextChangeOperationIndex !== undefined
+ ) {
+ const nextChangeOperation = recording.resolved.operations[nextChangeOperationIndex];
+ const nextClassification = classifications.get(nextChangeOperationIndex) ?? 'ambiguous';
+ const nextDelta = nextChangeOperation.time - previousEditTime;
+ const continuesNearbyUserIntent = (
+ nextClassification === 'accepted'
+ || nextClassification === 'partially-accepted'
+ || (nextClassification === 'user' && nextDelta > 0 && nextDelta < ORACLE_EDIT_IDLE_MS)
+ ) && areDocumentChangesWithinLineGap(
+ recording,
+ operationIndices[operationIndices.length - 1],
+ nextChangeOperationIndex,
+ ORACLE_CURSOR_CONTINUATION_LINE_GAP,
+ );
+ if (continuesNearbyUserIntent) {
+ continue;
+ }
+ if (!doesOperationContinueOracle(recording, operationIndices, nextChangeOperationIndex)) {
+ return {
+ operationIndices,
+ cursorBoundaryOperationIndex: i,
+ stopReason: 'cursor-move',
+ };
+ }
+ return {
+ operationIndices,
+ cursorBoundaryOperationIndex: undefined,
+ stopReason: 'touching-boundary',
+ };
+ }
return {
operationIndices,
cursorBoundaryOperationIndex: i,
@@ -619,21 +667,39 @@ function collectOracle(
}
if (operation.kind === OperationKind.SetContent || operation.kind === OperationKind.Restore) {
+ let stopReason: WorkspaceOracleCollectionStopReason;
+ if (operation.documentId !== pivot.documentId) {
+ stopReason = 'other-document-edit';
+ } else if (operationIndices.length > 0) {
+ stopReason = 'touching-boundary';
+ } else {
+ stopReason = 'ambiguous-edit';
+ }
return {
operationIndices,
cursorBoundaryOperationIndex: undefined,
- stopReason: operation.documentId === pivot.documentId ? 'ambiguous-edit' : 'other-document-edit',
+ stopReason,
};
}
if (operation.kind !== OperationKind.Changed) {
continue;
}
+ if (isNoOpDocumentChange(recording, operation)) {
+ continue;
+ }
if (operation.documentId !== pivot.documentId) {
return { operationIndices, cursorBoundaryOperationIndex: undefined, stopReason: 'other-document-edit' };
}
const classification = classifications.get(operation.operationIdx) ?? 'ambiguous';
- if (classification !== 'user') {
+ if (classification !== 'user' && classification !== 'accepted' && classification !== 'partially-accepted') {
+ if (operationIndices.length > 0 && doesOperationContinueOracle(recording, operationIndices, operation.operationIdx)) {
+ return {
+ operationIndices,
+ cursorBoundaryOperationIndex: undefined,
+ stopReason: 'touching-boundary',
+ };
+ }
return {
operationIndices,
cursorBoundaryOperationIndex: undefined,
@@ -641,21 +707,128 @@ function collectOracle(
};
}
- const delta = operation.time - previousEditTime;
- if (delta <= 0 || delta >= WORKSPACE_RECORDING_ORACLE_IDLE_MS) {
- return { operationIndices, cursorBoundaryOperationIndex: undefined, stopReason: 'idle-gap' };
+ if (classification === 'user') {
+ const delta = operation.time - previousEditTime;
+ if (delta <= 0 || delta >= ORACLE_EDIT_IDLE_MS) {
+ if (operationIndices.length > 0 && doesOperationContinueOracle(recording, operationIndices, operation.operationIdx)) {
+ return {
+ operationIndices,
+ cursorBoundaryOperationIndex: undefined,
+ stopReason: 'touching-boundary',
+ };
+ }
+ return { operationIndices, cursorBoundaryOperationIndex: undefined, stopReason: 'idle-gap' };
+ }
}
operationIndices.push(operation.operationIdx);
previousEditTime = operation.time;
- if (operationIndices.length === WORKSPACE_RECORDING_ORACLE_EDIT_LIMIT) {
- return { operationIndices, cursorBoundaryOperationIndex: undefined, stopReason: 'edit-limit' };
- }
}
return { operationIndices, cursorBoundaryOperationIndex: undefined, stopReason: 'end-of-recording' };
}
+function findNextDocumentChangeOperationIndex(
+ recording: IWorkspaceRecording,
+ startOperationIndex: number,
+ documentId: number,
+): number | undefined {
+ for (let i = startOperationIndex; i < recording.resolved.operations.length; i++) {
+ const operation = recording.resolved.operations[i];
+ if (operation.kind === OperationKind.SetContent || operation.kind === OperationKind.Restore) {
+ return undefined;
+ }
+ if (operation.kind !== OperationKind.Changed || isNoOpDocumentChange(recording, operation)) {
+ continue;
+ }
+ return operation.documentId === documentId ? operation.operationIdx : undefined;
+ }
+ return undefined;
+}
+
+function areDocumentChangesWithinLineGap(
+ recording: IWorkspaceRecording,
+ firstOperationIndex: number,
+ secondOperationIndex: number,
+ maxLineGap: number,
+): boolean {
+ const first = getDocumentChangeLineRange(recording, firstOperationIndex);
+ const second = getDocumentChangeLineRange(recording, secondOperationIndex);
+ if (!first || !second || first.documentId !== second.documentId) {
+ return false;
+ }
+ if (first.endLine < second.startLine) {
+ return second.startLine - first.endLine - 1 <= maxLineGap;
+ }
+ if (second.endLine < first.startLine) {
+ return first.startLine - second.endLine - 1 <= maxLineGap;
+ }
+ return true;
+}
+
+function getDocumentChangeLineRange(
+ recording: IWorkspaceRecording,
+ operationIndex: number,
+): { documentId: number; startLine: number; endLine: number } | undefined {
+ const operation = recording.resolved.operations[operationIndex];
+ if (!operation || operation.kind !== OperationKind.Changed || operation.edit.replacements.length === 0) {
+ return undefined;
+ }
+ const state = recording.resolved.getDocument(operation.documentId).getState(operation.documentStateIdBefore);
+ const transformer = new StringText(state.value).getTransformer();
+ let startLine = Number.POSITIVE_INFINITY;
+ let endLine = Number.NEGATIVE_INFINITY;
+ for (const replacement of operation.edit.replacements) {
+ startLine = Math.min(startLine, transformer.getPosition(replacement.replaceRange.start).lineNumber - 1);
+ endLine = Math.max(endLine, transformer.getPosition(replacement.replaceRange.endExclusive).lineNumber - 1);
+ }
+ return { documentId: operation.documentId, startLine, endLine };
+}
+
+function isNoOpDocumentChange(recording: IWorkspaceRecording, operation: Operation): boolean {
+ if (operation.kind !== OperationKind.Changed) {
+ return false;
+ }
+ const document = recording.resolved.getDocument(operation.documentId);
+ return document.getState(operation.documentStateIdBefore).value === document.getState(operation.documentStateIdAfter).value;
+}
+
+function composeOracleEdits(
+ recording: IWorkspaceRecording,
+ operationIndices: readonly number[],
+ maxOracleEdits: number,
+): ISerializedEdit {
+ return composeAndLimitSerializedEdits(getSerializedOperationEdits(recording, operationIndices), maxOracleEdits);
+}
+
+function getSerializedOperationEdits(
+ recording: IWorkspaceRecording,
+ operationIndices: readonly number[],
+): ISerializedEdit[] {
+ return operationIndices.map(operationIndex => {
+ const operation = recording.resolved.operations[operationIndex];
+ if (!operation || operation.kind !== OperationKind.Changed) {
+ throw new Error(`Workspace recording oracle operation ${operationIndex} is not a document change`);
+ }
+ return serializeStringEdit(operation.edit);
+ });
+}
+
+function doesOperationContinueOracle(
+ recording: IWorkspaceRecording,
+ operationIndices: readonly number[],
+ nextOperationIndex: number,
+): boolean {
+ const operation = recording.resolved.operations[nextOperationIndex];
+ if (!operation || operation.kind !== OperationKind.Changed) {
+ return false;
+ }
+ return doesSerializedEditContinueOracle(
+ getSerializedOperationEdits(recording, operationIndices),
+ serializeStringEdit(operation.edit),
+ );
+}
+
function deduplicateCandidates(
recording: IWorkspaceRecording,
candidates: readonly IWorkspaceRecordingSampleDescriptor[],
@@ -669,7 +842,12 @@ function deduplicateCandidates(
for (const candidate of candidates) {
const sample = materializeWorkspaceRecordingSample(recording, candidate);
const inputDigest = digest(sample.entries.slice(0, sample.pivotEntryIndex + 1));
- const labelDigest = digest(sample.entries.slice(sample.pivotEntryIndex + 1));
+ const labelDigest = digest({
+ oracleEdits: candidate.oracleEdits,
+ cursorBoundaries: sample.entries
+ .slice(sample.pivotEntryIndex + 1)
+ .filter(entry => entry.kind === 'selectionChanged'),
+ });
const group = groups.get(inputDigest);
if (!group) {
groups.set(inputDigest, { labelDigest, candidate, conflicting: false });
diff --git a/extensions/markdown-language-features/media/preview-dark.svg b/extensions/markdown-language-features/media/preview-dark.svg
index ec71ea81143..dbe102fcce8 100644
--- a/extensions/markdown-language-features/media/preview-dark.svg
+++ b/extensions/markdown-language-features/media/preview-dark.svg
@@ -1,3 +1,3 @@
diff --git a/extensions/markdown-language-features/media/preview-light.svg b/extensions/markdown-language-features/media/preview-light.svg
index 4a6b85b5839..1f98e181a74 100644
--- a/extensions/markdown-language-features/media/preview-light.svg
+++ b/extensions/markdown-language-features/media/preview-light.svg
@@ -1,3 +1,3 @@
diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts
index fc4c2fcbfc3..c48cb2418ec 100644
--- a/src/vs/base/browser/ui/menu/menu.ts
+++ b/src/vs/base/browser/ui/menu/menu.ts
@@ -191,7 +191,7 @@ export class Menu extends ActionBar {
}
}));
- this._register(addDisposableListener(this.actionsList, EventType.MOUSE_OVER, e => {
+ this._register(addDisposableListener(this.actionsList, EventType.MOUSE_MOVE, e => {
let target = e.target as HTMLElement;
if (!target || !isAncestor(target, this.actionsList) || target === this.actionsList) {
return;
@@ -203,6 +203,11 @@ export class Menu extends ActionBar {
if (target.classList.contains('action-item')) {
const lastFocusedItem = this.focusedItem;
+ // Moving within the focused item is the common case; skip the item lookup for it
+ if (lastFocusedItem !== undefined && this.actionsList.children[lastFocusedItem] === target) {
+ return;
+ }
+
this.setFocusedItem(target);
if (lastFocusedItem !== this.focusedItem) {
@@ -790,7 +795,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem {
}
}));
- this._register(addDisposableListener(this.element, EventType.MOUSE_OVER, e => {
+ this._register(addDisposableListener(this.element, EventType.MOUSE_MOVE, e => {
if (!this.mouseOver) {
this.mouseOver = true;
diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts
index 723d8831872..5678ca05378 100644
--- a/src/vs/base/common/event.ts
+++ b/src/vs/base/common/event.ts
@@ -993,9 +993,13 @@ export function setGlobalLeakWarningThreshold(n: number): IDisposable {
};
}
-class LeakageMonitor {
+let leakageMonitorId = 1;
- private static _idPool = 1;
+function nextLeakageMonitorName(): string {
+ return (leakageMonitorId++).toString(16).padStart(3, '0');
+}
+
+class LeakageMonitor {
private _stacks: Map | undefined;
private _warnCountdown: number = 0;
@@ -1003,7 +1007,7 @@ class LeakageMonitor {
constructor(
private readonly _errorHandler: (err: Error) => void,
readonly threshold: number,
- readonly name: string = (LeakageMonitor._idPool++).toString(16).padStart(3, '0')
+ readonly name: string = nextLeakageMonitorName()
) { }
dispose(): void {
@@ -1020,8 +1024,9 @@ class LeakageMonitor {
if (!this._stacks) {
this._stacks = new Map();
}
- const count = (this._stacks.get(stack.value) || 0);
- this._stacks.set(stack.value, count + 1);
+ const stackKey = stack.value;
+ const count = (this._stacks.get(stackKey) || 0);
+ this._stacks.set(stackKey, count + 1);
this._warnCountdown -= 1;
if (this._warnCountdown <= 0) {
@@ -1041,8 +1046,12 @@ class LeakageMonitor {
}
return () => {
- const count = (this._stacks!.get(stack.value) || 0);
- this._stacks!.set(stack.value, count - 1);
+ const count = (this._stacks!.get(stackKey) || 0);
+ if (count <= 1) {
+ this._stacks!.delete(stackKey);
+ } else {
+ this._stacks!.set(stackKey, count - 1);
+ }
};
}
@@ -1161,7 +1170,10 @@ const forEachListener = (listeners: ListenerOrListeners, fn: (c: ListenerC
export class Emitter {
private readonly _options?: EmitterOptions;
- private readonly _leakageMon?: LeakageMonitor;
+ private readonly _leakWarningThreshold?: number;
+ private readonly _leakWarningName?: string;
+ private readonly _leakWarningErrorHandler?: (err: Error) => void;
+ private _leakageMon?: LeakageMonitor;
private readonly _perfMon?: EventProfiling;
private _disposed?: true;
private _event?: Event;
@@ -1195,13 +1207,22 @@ export class Emitter {
constructor(options?: EmitterOptions) {
this._options = options;
- this._leakageMon = (_globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold)
- ? new LeakageMonitor(options?.onListenerError ?? onUnexpectedError, this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold, this._options?.leakWarningName) :
- undefined;
+ if (_globalLeakWarningThreshold > 0 || this._options?.leakWarningThreshold) {
+ this._leakWarningThreshold = this._options?.leakWarningThreshold ?? _globalLeakWarningThreshold;
+ this._leakWarningName = this._options?.leakWarningName ?? nextLeakageMonitorName();
+ this._leakWarningErrorHandler = this._options?.onListenerError ?? onUnexpectedError;
+ }
this._perfMon = this._options?._profName ? new EventProfiling(this._options._profName) : undefined;
this._deliveryQueue = this._options?.deliveryQueue as EventDeliveryQueuePrivate | undefined;
}
+ private _getLeakageMonitor(): LeakageMonitor | undefined {
+ if (this._leakWarningThreshold === undefined || this._leakWarningName === undefined || this._leakWarningErrorHandler === undefined) {
+ return undefined;
+ }
+ return this._leakageMon ??= new LeakageMonitor(this._leakWarningErrorHandler, this._leakWarningThreshold, this._leakWarningName);
+ }
+
dispose() {
if (!this._disposed) {
this._disposed = true;
@@ -1241,17 +1262,20 @@ export class Emitter {
*/
get event(): Event {
this._event ??= (callback: (e: T) => unknown, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {
- if (this._leakageMon && this._size > this._leakageMon.threshold ** 2) {
- const message = `[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;
- console.warn(message);
+ if (this._leakWarningThreshold !== undefined && this._size > this._leakWarningThreshold ** 2) {
+ const leakageMon = this._getLeakageMonitor();
+ if (leakageMon) {
+ const message = `[${leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${leakageMon.threshold})`;
+ console.warn(message);
- const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1];
- const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular';
- const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0], this._size, this._options?.leakWarningName);
- const errorHandler = this._options?.onListenerError || onUnexpectedError;
- errorHandler(error);
+ const tuple = leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1];
+ const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular';
+ const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0], this._size, this._options?.leakWarningName);
+ const errorHandler = this._options?.onListenerError || onUnexpectedError;
+ errorHandler(error);
- return Disposable.None;
+ return Disposable.None;
+ }
}
if (this._disposed) {
@@ -1267,10 +1291,13 @@ export class Emitter {
let removeMonitor: Function | undefined;
let stack: Stacktrace | undefined;
- if (this._leakageMon && this._size >= Math.ceil(this._leakageMon.threshold * 0.2)) {
- // check and record this emitter for potential leakage
- contained.stack = Stacktrace.create();
- removeMonitor = this._leakageMon.check(contained.stack, this._size + 1);
+ if (this._leakWarningThreshold !== undefined && this._size >= Math.ceil(this._leakWarningThreshold * 0.2)) {
+ const leakageMon = this._getLeakageMonitor();
+ if (leakageMon) {
+ // check and record this emitter for potential leakage
+ contained.stack = Stacktrace.create();
+ removeMonitor = leakageMon.check(contained.stack, this._size + 1);
+ }
}
if (_enableDisposeWithListenerWarning) {
diff --git a/src/vs/base/test/browser/ui/menu/menu.test.ts b/src/vs/base/test/browser/ui/menu/menu.test.ts
index 3544598e91e..3c7a847821f 100644
--- a/src/vs/base/test/browser/ui/menu/menu.test.ts
+++ b/src/vs/base/test/browser/ui/menu/menu.test.ts
@@ -4,14 +4,83 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
-import { $, append, getWindow } from '../../../../browser/dom.js';
-import { getMenuWidgetCSS, unthemedMenuStyles } from '../../../../browser/ui/menu/menu.js';
+import sinon from 'sinon';
+import { $, append, EventType, getWindow } from '../../../../browser/dom.js';
+import { getMenuWidgetCSS, Menu, unthemedMenuStyles } from '../../../../browser/ui/menu/menu.js';
+import { Action, SubmenuAction } from '../../../../common/actions.js';
import { toDisposable } from '../../../../common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js';
suite('Menu', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+ teardown(() => {
+ sinon.restore();
+ });
+
+ // A menu positioned under a resting pointer receives `mouseover` without any
+ // `mousemove`, so hover must react to `mousemove` to leave keyboard focus alone.
+ test('stationary mouse does not change focus (#110594, #148158)', () => {
+ const host = append(document.body, $('div'));
+ disposables.add(toDisposable(() => host.remove()));
+ const menu = disposables.add(new Menu(host, [
+ disposables.add(new Action('first', 'First')),
+ disposables.add(new Action('second', 'Second'))
+ ], {}, unthemedMenuStyles));
+ const actionItems = Array.from(host.querySelectorAll('.action-item'));
+ const getFocusedActions = () => actionItems.map((_, index) => menu.isFocused(index));
+
+ menu.focus(true);
+ const focusStates = [getFocusedActions()];
+
+ actionItems[1].dispatchEvent(new MouseEvent(EventType.MOUSE_OVER, { bubbles: true }));
+ focusStates.push(getFocusedActions());
+
+ actionItems[1].dispatchEvent(new MouseEvent(EventType.MOUSE_MOVE, { bubbles: true }));
+ focusStates.push(getFocusedActions());
+
+ actionItems[1].dispatchEvent(new MouseEvent(EventType.MOUSE_MOVE, { bubbles: true }));
+ focusStates.push(getFocusedActions());
+
+ actionItems[0].dispatchEvent(new MouseEvent(EventType.MOUSE_MOVE, { bubbles: true }));
+ focusStates.push(getFocusedActions());
+
+ assert.deepStrictEqual(focusStates, [
+ [true, false],
+ [true, false],
+ [false, true],
+ [false, true],
+ [true, false]
+ ]);
+ });
+
+ test('stationary mouse does not open submenu (#110594, #148158)', () => {
+ const clock = sinon.useFakeTimers();
+ const host = append(document.body, $('div'));
+ disposables.add(toDisposable(() => host.remove()));
+ const submenu = new SubmenuAction('submenu', 'Submenu', [
+ disposables.add(new Action('child', 'Child'))
+ ]);
+ disposables.add(new Menu(host, [submenu], {}, unthemedMenuStyles));
+ const submenuAction = host.querySelector('.action-item')!;
+ const submenuItem = submenuAction.querySelector('.action-menu-item')!;
+
+ submenuAction.dispatchEvent(new MouseEvent(EventType.MOUSE_OVER, { bubbles: true }));
+ clock.tick(250);
+ const expandedAfterMouseOver = submenuItem.getAttribute('aria-expanded');
+
+ submenuAction.dispatchEvent(new MouseEvent(EventType.MOUSE_MOVE, { bubbles: true }));
+ clock.tick(250);
+
+ assert.deepStrictEqual({
+ expandedAfterMouseOver,
+ expandedAfterMouseMove: submenuItem.getAttribute('aria-expanded')
+ }, {
+ expandedAfterMouseOver: 'false',
+ expandedAfterMouseMove: 'true'
+ });
+ });
+
test('high contrast selection outline does not apply to nested submenu items (#327543)', () => {
const host = append(document.body, $('div'));
disposables.add(toDisposable(() => host.remove()));
diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts
index cf3c252b909..5e5ae1a6d95 100644
--- a/src/vs/base/test/common/event.test.ts
+++ b/src/vs/base/test/common/event.test.ts
@@ -7,7 +7,7 @@ import { stub } from 'sinon';
import { timeout } from '../../common/async.js';
import { CancellationToken } from '../../common/cancellation.js';
import { errorHandler, setUnexpectedErrorHandler } from '../../common/errors.js';
-import { AsyncEmitter, DebounceEmitter, DynamicListEventMultiplexer, Emitter, Event, EventBufferer, EventMultiplexer, IWaitUntil, ListenerLeakError, ListenerRefusalError, MicrotaskEmitter, PauseableEmitter, Relay, createEventDeliveryQueue } from '../../common/event.js';
+import { AsyncEmitter, DebounceEmitter, DynamicListEventMultiplexer, Emitter, Event, EventBufferer, EventMultiplexer, IWaitUntil, ListenerLeakError, ListenerRefusalError, MicrotaskEmitter, PauseableEmitter, Relay, createEventDeliveryQueue, setGlobalLeakWarningThreshold } from '../../common/event.js';
import { DisposableStore, IDisposable, isDisposable, setDisposableTracker, DisposableTracker } from '../../common/lifecycle.js';
import { observableValue, transaction } from '../../common/observable.js';
import { MicrotaskDelay } from '../../common/symbols.js';
@@ -415,6 +415,109 @@ suite('Event', function () {
store.dispose();
});
+ test('Emitter leak warnings track only active listener stacks', () => {
+ const consoleWarn = stub(console, 'warn');
+ const errors: Error[] = [];
+ class TestEmitter extends Emitter {
+ setListenerCount(listenerCount: number): void {
+ this._size = listenerCount;
+ }
+ }
+ const emitter = ds.add(new TestEmitter({
+ leakWarningThreshold: 3,
+ leakWarningName: 'test',
+ onListenerError: error => errors.push(error),
+ }));
+
+ const addStackAListener = () => emitter.event(() => { });
+ const addStackBListener = () => emitter.event(() => { });
+ const addStackCListener = () => emitter.event(() => { });
+
+ try {
+ emitter.setListenerCount(2);
+ const stackAListeners = Array.from({ length: 3 }, () => addStackAListener());
+ stackAListeners[0].dispose();
+ const stackBListener = addStackBListener();
+ const stackCListener = addStackCListener();
+
+ stackAListeners.slice(1).forEach(listener => listener.dispose());
+ stackBListener.dispose();
+ stackCListener.dispose();
+ emitter.setListenerCount(10);
+ emitter.event(() => { });
+
+ assert.deepStrictEqual(errors.map(error => ({
+ name: error.name,
+ details: error instanceof ListenerLeakError ? error.details : undefined,
+ hasUnknownStack: error.stack === 'UNKNOWN stack',
+ })), [
+ {
+ name: 'ListenerLeakError',
+ details: '[test] potential listener LEAK detected, having 3 listeners already. MOST frequent listener (1):',
+ hasUnknownStack: false,
+ },
+ {
+ name: 'ListenerLeakError',
+ details: '[test] potential listener LEAK detected, having 5 listeners already. MOST frequent listener (3):',
+ hasUnknownStack: false,
+ },
+ {
+ name: 'ListenerLeakError',
+ details: '[test] potential listener LEAK detected, having 6 listeners already. MOST frequent listener (2):',
+ hasUnknownStack: false,
+ },
+ {
+ name: 'ListenerRefusalError',
+ details: '[test] REFUSES to accept new listeners because it exceeded its threshold by far (10 vs 3). HINT: Stack shows most frequent listener (-1-times)',
+ hasUnknownStack: true,
+ },
+ ]);
+ } finally {
+ consoleWarn.restore();
+ }
+ });
+
+ test('Emitter captures global leak warning configuration at construction', () => {
+ const consoleWarn = stub(console, 'warn');
+ const errors: Error[] = [];
+ let restoreThreshold: IDisposable | undefined = setGlobalLeakWarningThreshold(3);
+ try {
+ const monitoredEmitter = ds.add(new Emitter({
+ leakWarningName: 'captured',
+ onListenerError: error => errors.push(error),
+ }));
+ restoreThreshold.dispose();
+ restoreThreshold = undefined;
+
+ const unmonitoredEmitter = ds.add(new Emitter({
+ onListenerError: error => errors.push(error),
+ }));
+ restoreThreshold = setGlobalLeakWarningThreshold(3);
+ const listeners = ds.add(new DisposableStore());
+ const monitorAllocation = [Object.hasOwn(monitoredEmitter, '_leakageMon')];
+ for (let i = 0; i < 3; i++) {
+ monitoredEmitter.event(() => { }, undefined, listeners);
+ unmonitoredEmitter.event(() => { }, undefined, listeners);
+ monitorAllocation.push(Object.hasOwn(monitoredEmitter, '_leakageMon'));
+ }
+ restoreThreshold.dispose();
+ restoreThreshold = undefined;
+
+ assert.deepStrictEqual({
+ errors: errors.map(error => error.message),
+ monitorAllocation,
+ unmonitoredEmitterHasMonitor: Object.hasOwn(unmonitoredEmitter, '_leakageMon'),
+ }, {
+ errors: ['[captured] potential listener LEAK detected, dominated'],
+ monitorAllocation: [false, false, true, true],
+ unmonitoredEmitterHasMonitor: false,
+ });
+ } finally {
+ restoreThreshold?.dispose();
+ consoleWarn.restore();
+ }
+ });
+
test('reusing event function and context', function () {
let counter = 0;
function listener() {
diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts
index 3ebad409489..7773bdb899b 100644
--- a/src/vs/code/electron-main/app.ts
+++ b/src/vs/code/electron-main/app.ts
@@ -737,7 +737,7 @@ export class CodeApplication extends Disposable {
// available and AI features are enabled there, which the main process
// cannot fully observe.
const agentHostStarter = new ElectronAgentHostStarter({ machineId, sqmId, devDeviceId }, this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService);
- this._register(appInstantiationService.createInstance(AgentHostProcessManager, agentHostStarter));
+ this._register(appInstantiationService.createInstance(AgentHostProcessManager, agentHostStarter, process.platform));
// Metered connection telemetry
appInstantiationService.invokeFunction(accessor => {
diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts
index 86436a376ec..22c563c291c 100644
--- a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts
+++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { addDisposableListener, getActiveElement, getShadowRoot } from '../../../../../base/browser/dom.js';
+import { addDisposableListener, getShadowRoot } from '../../../../../base/browser/dom.js';
import { IDisposable, Disposable } from '../../../../../base/common/lifecycle.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
@@ -67,7 +67,7 @@ export class FocusTracker extends Disposable {
public refreshFocusState(): void {
const shadowRoot = getShadowRoot(this._domNode);
- const activeElement = shadowRoot ? shadowRoot.activeElement : getActiveElement();
+ const activeElement = shadowRoot ? shadowRoot.activeElement : this._domNode.ownerDocument.activeElement;
const focused = this._domNode === activeElement;
this._handleFocusedChanged(focused);
}
diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
index 07a3e9fcf39..777f09a9e27 100644
--- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
+++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts
@@ -16,6 +16,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { ICodeEditor } from '../../../../browser/editorBrowser.js';
import { observableCodeEditor } from '../../../../browser/observableCodeEditor.js';
+import product from '../../../../../platform/product/common/product.js';
import { EditorOption } from '../../../../common/config/editorOptions.js';
import { CursorColumns } from '../../../../common/core/cursorColumns.js';
import { LineRange } from '../../../../common/core/ranges/lineRange.js';
@@ -124,7 +125,7 @@ export class InlineCompletionsModel extends Disposable {
@IDefaultAccountService defaultAccountService: IDefaultAccountService,
) {
super();
- this._source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this._textModelVersionId, this._debounceValue, this.primaryPosition));
+ this._source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this._textModelVersionId, this._debounceValue, this.primaryPosition, product.defaultChatAgent?.completionsEnablementSetting));
this.lastTriggerKind = this._source.inlineCompletions.map(this, v => v?.request?.context.triggerKind);
this._editorObs = observableCodeEditor(this._editor);
diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts
index 1102a9a4f05..0648e28f945 100644
--- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts
+++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts
@@ -20,7 +20,6 @@ import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLik
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
-import product from '../../../../../platform/product/common/product.js';
import { StringEdit } from '../../../../common/core/edits/stringEdit.js';
import { Position } from '../../../../common/core/position.js';
import { Range } from '../../../../common/core/range.js';
@@ -82,6 +81,7 @@ export class InlineCompletionsSource extends Disposable {
public readonly suggestWidgetInlineCompletions = this._state.map(this, v => v.suggestWidgetInlineCompletions);
private readonly _renameProcessor: RenameSymbolProcessor;
+ private readonly _dataChannelTelemetryService: DataChannelForwardingTelemetryService;
private _completionsEnabled: Record | undefined = undefined;
@@ -90,6 +90,7 @@ export class InlineCompletionsSource extends Disposable {
private readonly _versionId: IObservableWithChange,
private readonly _debounceValue: IFeatureDebounceInformation,
private readonly _cursorPosition: IObservable,
+ completionsEnablementSetting: string | undefined,
@ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService,
@ILogService private readonly _logService: ILogService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@@ -98,6 +99,7 @@ export class InlineCompletionsSource extends Disposable {
@ITextModelService private readonly _textModelService: ITextModelService,
) {
super();
+ this._dataChannelTelemetryService = this._instantiationService.createInstance(DataChannelForwardingTelemetryService);
this._loggingEnabled = observableConfigValue('editor.inlineSuggest.logFetch', false, this._configurationService).recomputeInitiallyAndOnChange(this._store);
this._sendRequestData = observableConfigValue('editor.inlineSuggest.emptyResponseInformation', true, this._configurationService).recomputeInitiallyAndOnChange(this._store);
this._structuredFetchLogger = this._register(this._instantiationService.createInstance(StructuredLogger.cast<
@@ -111,12 +113,11 @@ export class InlineCompletionsSource extends Disposable {
this.clearOperationOnTextModelChange.recomputeInitiallyAndOnChange(this._store);
- const enablementSetting = product.defaultChatAgent?.completionsEnablementSetting ?? undefined;
- if (enablementSetting) {
- this._updateCompletionsEnablement(enablementSetting);
+ if (completionsEnablementSetting) {
+ this._updateCompletionsEnablement(completionsEnablementSetting);
this._register(this._configurationService.onDidChangeConfiguration(e => {
- if (e.affectsConfiguration(enablementSetting)) {
- this._updateCompletionsEnablement(enablementSetting);
+ if (e.affectsConfiguration(completionsEnablementSetting)) {
+ this._updateCompletionsEnablement(completionsEnablementSetting);
}
}));
}
@@ -550,8 +551,7 @@ export class InlineCompletionsSource extends Disposable {
editKind: undefined,
};
- const dataChannel = this._instantiationService.createInstance(DataChannelForwardingTelemetryService);
- sendInlineCompletionsEndOfLifeTelemetry(dataChannel, emptyEndOfLifeEvent);
+ sendInlineCompletionsEndOfLifeTelemetry(this._dataChannelTelemetryService, emptyEndOfLifeEvent);
}
public clearSuggestWidgetInlineCompletions(tx: ITransaction): void {
diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts
index 2f62abcdc3f..6ab4d6b3d1a 100644
--- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts
+++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts
@@ -4,10 +4,19 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
-import { timeout } from '../../../../../base/common/async.js';
+import { DeferredPromise, timeout } from '../../../../../base/common/async.js';
+import { Event } from '../../../../../base/common/event.js';
+import { observableValue } from '../../../../../base/common/observable.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
+import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
+import { IDataChannelService } from '../../../../../platform/dataChannel/common/dataChannel.js';
+import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
+import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { Range } from '../../../../common/core/range.js';
+import { InlineCompletionTriggerKind, InlineCompletions, InlineCompletionsProvider, ProviderId } from '../../../../common/languages.js';
import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js';
+import { InlineCompletionEditorType } from '../../browser/model/provideInlineCompletions.js';
+import { InlineCompletionsSource } from '../../browser/model/inlineCompletionsSource.js';
import { IWithAsyncTestCodeEditorAndInlineCompletionsModel, MockInlineCompletionsProvider, withAsyncTestCodeEditorAndInlineCompletionsModel } from './utils.js';
import { ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js';
import { Selection } from '../../../../common/core/selection.js';
@@ -15,6 +24,69 @@ import { Selection } from '../../../../common/core/selection.js';
suite('Inline Completions', () => {
ensureNoDisposablesAreLeakedInTestSuite();
+ test('Emits empty response telemetry after instantiation service disposal', async function () {
+ const providerStarted = new DeferredPromise();
+ const providerResponse = new DeferredPromise();
+ const provider: InlineCompletionsProvider = {
+ providerId: ProviderId.fromExtensionId('GitHub.copilot'),
+ provideInlineCompletions: () => {
+ providerStarted.complete();
+ return providerResponse.p;
+ },
+ disposeInlineCompletions: () => { },
+ };
+ const sentChannelIds: string[] = [];
+ const dataChannelService: IDataChannelService = {
+ _serviceBrand: undefined,
+ onDidSendData: Event.None,
+ getDataChannel: channelId => ({
+ sendData: () => sentChannelIds.push(channelId)
+ })
+ };
+ const serviceCollection = new ServiceCollection(
+ [IDataChannelService, dataChannelService],
+ [IConfigurationService, new TestConfigurationService({
+ 'github.copilot.enable': { '*': true },
+ })],
+ );
+
+ await withAsyncTestCodeEditorAndInlineCompletionsModel('', { provider, serviceCollection },
+ async ({ editor, model, store, instantiationService }) => {
+ const source = store.add(instantiationService.createInstance(
+ InlineCompletionsSource,
+ model.textModel,
+ model._textModelVersionId,
+ { get: () => 0, update: () => 0, default: () => 0 },
+ observableValue('testCursorPosition', editor.getPosition()!),
+ 'github.copilot.enable',
+ ));
+ const request = source.fetch([provider], undefined, {
+ triggerKind: InlineCompletionTriggerKind.Explicit,
+ selectedSuggestionInfo: undefined,
+ earliestShownDateTime: 0,
+ includeInlineCompletions: true,
+ includeInlineEdits: false,
+ requestIssuedDateTime: Date.now(),
+ }, undefined, false, observableValue('userJumpedToActiveCompletion', false), {
+ startTime: Date.now(),
+ sku: undefined,
+ editorType: InlineCompletionEditorType.TextEditor,
+ languageId: 'plaintext',
+ availableProviders: [provider.providerId!],
+ reason: '',
+ typingInterval: 0,
+ typingIntervalCharacterCount: 0,
+ });
+ await providerStarted.p;
+ instantiationService.dispose();
+ await providerResponse.complete({ items: [] });
+ await request;
+ }
+ );
+
+ assert.deepStrictEqual(sentChannelIds, ['editTelemetry']);
+ });
+
test('Does not trigger automatically if disabled', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts
index 073284c74c1..504909c723b 100644
--- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts
+++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts
@@ -17,6 +17,7 @@ import { IAccessibilitySignalService } from '../../../../../platform/accessibili
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
+import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js';
import { IBulkEditService } from '../../../../browser/services/bulkEditService.js';
import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js';
@@ -245,6 +246,7 @@ export interface IWithAsyncTestCodeEditorAndInlineCompletionsModel {
context: GhostTextContext;
store: DisposableStore;
logger: ITraceLogger;
+ instantiationService: TestInstantiationService;
}
export async function withAsyncTestCodeEditorAndInlineCompletionsModel(
@@ -320,7 +322,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel(
const model = controller.model.get()!;
const context = new GhostTextContext(model, editor, logger);
try {
- result = await callback({ editor, editorViewModel, model, context, store: disposableStore, logger });
+ result = await callback({ editor, editorViewModel, model, context, store: disposableStore, logger, instantiationService });
} finally {
context.dispose();
model.dispose();
diff --git a/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts
new file mode 100644
index 00000000000..7f485d3b6ee
--- /dev/null
+++ b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts
@@ -0,0 +1,39 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import { toDisposable } from '../../../../base/common/lifecycle.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
+import { NullLogService } from '../../../../platform/log/common/log.js';
+import { FocusTracker } from '../../../browser/controller/editContext/native/nativeEditContextUtils.js';
+
+suite('NativeEditContextUtils', () => {
+
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ test('tracks focus in the DOM node owner document', () => {
+ const iframe = document.createElement('iframe');
+ document.body.appendChild(iframe);
+ disposables.add(toDisposable(() => iframe.remove()));
+
+ const target = iframe.contentDocument!.createElement('div');
+ target.tabIndex = 0;
+ iframe.contentDocument!.body.appendChild(target);
+
+ let focused = false;
+ const tracker = disposables.add(new FocusTracker(new NullLogService(), target, value => focused = value));
+ tracker.focus();
+
+ assert.deepStrictEqual({
+ activeElement: iframe.contentDocument!.activeElement === target,
+ focused,
+ trackerFocused: tracker.isFocused,
+ }, {
+ activeElement: true,
+ focused: true,
+ trackerFocused: true,
+ });
+ });
+});
diff --git a/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts b/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts
index 99eb6f04990..a6f07f92dbe 100644
--- a/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts
+++ b/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts
@@ -16,6 +16,7 @@ import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import type { IChannel } from '../../../base/parts/ipc/common/ipc.js';
import { AhpJsonlLogger, getAhpLogByteLength } from '../common/ahpJsonlLogger.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IClientTransport } from '../common/state/sessionTransport.js';
import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js';
@@ -48,6 +49,7 @@ export class AgentHostIpcChannelTransport extends Disposable implements IClientT
constructor(
private readonly _channel: IChannel,
private readonly _ahpLogger?: AhpJsonlLogger,
+ readonly clientConnectionKind = AgentHostClientConnectionKind.Unknown,
) {
super();
}
diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
index fbbb8604daf..09d9aa7e09c 100644
--- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
+++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
@@ -40,6 +40,7 @@ import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETR
import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js';
import { AgentHostTelemetryLevelConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
import { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js';
+import { toClientConnectionTelemetryMeta } from '../common/agentHostTelemetry.js';
import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js';
import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js';
import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js';
@@ -443,6 +444,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
clientId: this._clientId,
clientInfo: this._clientInfo,
+ ...this._clientConnectionTelemetryMeta(),
initialSubscriptions: [ROOT_STATE_URI],
}, { bypassInitializeQueue: true });
this._applyInitializeResult(result);
@@ -638,6 +640,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
clientId: this._clientId,
lastSeenServerSeq,
subscriptions,
+ ...this._clientConnectionTelemetryMeta(),
}, { bypassReconnectGate: true });
} catch (error) {
if (!(error instanceof ProtocolError) || error.code !== AhpErrorCodes.NotFound) {
@@ -651,12 +654,18 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
clientId: this._clientId,
clientInfo: this._clientInfo,
+ ...this._clientConnectionTelemetryMeta(),
initialSubscriptions: subscriptions,
}, { bypassReconnectGate: true });
this._applyInitializeResult(initializeResult);
return { type: ReconnectResultType.Snapshot, snapshots: initializeResult.snapshots ?? [] };
}
+ private _clientConnectionTelemetryMeta(): { _meta: Record } | Record {
+ const meta = toClientConnectionTelemetryMeta(this._transport.clientConnectionKind);
+ return meta ? { _meta: meta } : {};
+ }
+
private _applyInitializeResult(result: CommandMap['initialize']['result']): void {
this._initializeResult.set(result, undefined);
this._serverSeq = result.serverSeq;
diff --git a/src/vs/platform/agentHost/browser/sshHostKeyTrustService.ts b/src/vs/platform/agentHost/browser/sshHostKeyTrustService.ts
new file mode 100644
index 00000000000..53e213b7554
--- /dev/null
+++ b/src/vs/platform/agentHost/browser/sshHostKeyTrustService.ts
@@ -0,0 +1,172 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Emitter, Event } from '../../../base/common/event.js';
+import { Disposable } from '../../../base/common/lifecycle.js';
+import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';
+import {
+ computeHostKeyStoreKey,
+ ISSHHostKeyTrustService,
+ type ISSHTrustedHost,
+ type ISSHTrustedHostKey,
+} from '../common/sshHostKeyTrust.js';
+
+/** Storage key for the JSON map of trusted SSH host keys. */
+export const SSH_HOST_KEY_TRUST_STORAGE_KEY = 'sshRemoteAgentHost.trustedHostKeys';
+
+/**
+ * Parse one persisted host key entry, returning `undefined` when any field is
+ * missing or the wrong shape. Trust data must never be reconstructed from
+ * partial input — a half-read entry could otherwise match a key it shouldn't.
+ */
+function parseTrustedHostKey(value: unknown): ISSHTrustedHostKey | undefined {
+ if (typeof value !== 'object' || value === null) {
+ return undefined;
+ }
+ const { keyType, fingerprint, addedAt, alias } = value as Record;
+ if (typeof keyType !== 'string' || !keyType
+ || typeof fingerprint !== 'string' || !fingerprint
+ || typeof addedAt !== 'number' || !Number.isFinite(addedAt)) {
+ return undefined;
+ }
+ return {
+ keyType,
+ fingerprint,
+ addedAt,
+ ...(typeof alias === 'string' && alias ? { alias } : undefined),
+ };
+}
+
+/**
+ * Parse the persisted trust map. A malformed entry is dropped rather than
+ * discarding the whole map, so one bad record never forces the user to
+ * re-accept every host they have ever trusted.
+ */
+export function parseTrustedHostKeys(raw: string | undefined): Map {
+ const hosts = new Map();
+ if (!raw) {
+ return hosts;
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return hosts;
+ }
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ return hosts;
+ }
+
+ for (const [storeKey, value] of Object.entries(parsed as Record)) {
+ if (!storeKey || !Array.isArray(value)) {
+ continue;
+ }
+ const keys: ISSHTrustedHostKey[] = [];
+ for (const entry of value) {
+ const key = parseTrustedHostKey(entry);
+ if (key) {
+ keys.push(key);
+ }
+ }
+ if (keys.length) {
+ hosts.set(storeKey, keys);
+ }
+ }
+ return hosts;
+}
+
+/**
+ * Split a `hostname:port` store key back into its parts. Returns `undefined`
+ * for anything that doesn't round-trip, so a corrupt key is skipped rather
+ * than surfacing a host with a bogus port in the "forget" picker.
+ */
+function parseStoreKey(storeKey: string): { host: string; port: number } | undefined {
+ const separator = storeKey.lastIndexOf(':');
+ if (separator <= 0) {
+ return undefined;
+ }
+ const host = storeKey.substring(0, separator);
+ const port = Number(storeKey.substring(separator + 1));
+ if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
+ return undefined;
+ }
+ return { host, port };
+}
+
+/**
+ * Storage-backed {@link ISSHHostKeyTrustService}. Persists at application
+ * scope with {@link StorageTarget.MACHINE} because host key trust is a
+ * property of this machine's view of the network and must not sync to other
+ * devices, where the same alias could resolve somewhere else entirely.
+ */
+export class SSHHostKeyTrustService extends Disposable implements ISSHHostKeyTrustService {
+
+ declare readonly _serviceBrand: undefined;
+
+ private readonly _onDidChangeTrustedHosts = this._register(new Emitter());
+ readonly onDidChangeTrustedHosts: Event = this._onDidChangeTrustedHosts.event;
+
+ constructor(
+ @IStorageService private readonly _storageService: IStorageService,
+ ) {
+ super();
+ }
+
+ getTrustedKeys(host: string, port: number): readonly ISSHTrustedHostKey[] {
+ return this._read().get(computeHostKeyStoreKey(host, port)) ?? [];
+ }
+
+ trustHostKey(host: string, port: number, key: ISSHTrustedHostKey): void {
+ const storeKey = computeHostKeyStoreKey(host, port);
+ const hosts = this._read();
+ const existing = hosts.get(storeKey) ?? [];
+ // One trusted key per algorithm: a rotated key supersedes the old one
+ // rather than leaving the superseded key permanently trusted.
+ const keys = existing.filter(k => k.keyType !== key.keyType);
+ keys.push(key);
+ hosts.set(storeKey, keys);
+ this._write(hosts);
+ this._onDidChangeTrustedHosts.fire(storeKey);
+ }
+
+ forgetHost(host: string, port: number): void {
+ const storeKey = computeHostKeyStoreKey(host, port);
+ const hosts = this._read();
+ if (!hosts.delete(storeKey)) {
+ return;
+ }
+ this._write(hosts);
+ this._onDidChangeTrustedHosts.fire(storeKey);
+ }
+
+ listTrustedHosts(): readonly ISSHTrustedHost[] {
+ const result: ISSHTrustedHost[] = [];
+ for (const [storeKey, keys] of this._read()) {
+ const parsed = parseStoreKey(storeKey);
+ if (parsed) {
+ result.push({ host: parsed.host, port: parsed.port, keys });
+ }
+ }
+ return result;
+ }
+
+ private _read(): Map {
+ return parseTrustedHostKeys(this._storageService.get(SSH_HOST_KEY_TRUST_STORAGE_KEY, StorageScope.APPLICATION));
+ }
+
+ private _write(hosts: Map): void {
+ if (hosts.size === 0) {
+ this._storageService.remove(SSH_HOST_KEY_TRUST_STORAGE_KEY, StorageScope.APPLICATION);
+ return;
+ }
+ this._storageService.store(
+ SSH_HOST_KEY_TRUST_STORAGE_KEY,
+ JSON.stringify(Object.fromEntries(hosts)),
+ StorageScope.APPLICATION,
+ StorageTarget.MACHINE,
+ );
+ }
+}
diff --git a/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts b/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts
index 22e2cc16b4a..96484945d1e 100644
--- a/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts
+++ b/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts
@@ -15,6 +15,7 @@
import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { IntervalTimer, disposableTimeout } from '../../../base/common/async.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IClientTransport } from '../common/state/sessionTransport.js';
import { Reassembler } from '../common/webPubSub/chunking.js';
@@ -96,6 +97,7 @@ export interface IWebPubSubRelayTransportOptions {
* 3. {@link dispose} (or a socket close/error) fires {@link onClose} once.
*/
export class WebPubSubRelayTransport extends Disposable implements IClientTransport {
+ readonly clientConnectionKind = AgentHostClientConnectionKind.WebPubSub;
private readonly _onMessage = this._register(new Emitter());
readonly onMessage = this._onMessage.event;
diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
index 448d9340eb2..93cd0cd8f2b 100644
--- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
+++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
@@ -11,6 +11,7 @@ import { Disposable } from '../../../base/common/lifecycle.js';
import { connectionTokenQueryName } from '../../../base/common/network.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { AhpJsonlLogger, getAhpLogByteLength, IAhpJsonlLoggerOptions } from '../common/ahpJsonlLogger.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IClientTransport } from '../common/state/sessionTransport.js';
import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js';
@@ -23,6 +24,7 @@ import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from
* Implements {@link IClientTransport} with JSON serialization and URI revival.
*/
export class WebSocketClientTransport extends Disposable implements IClientTransport {
+ readonly clientConnectionKind = AgentHostClientConnectionKind.DirectWebSocket;
private readonly _onMessage = this._register(new Emitter());
readonly onMessage = this._onMessage.event;
diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts
index 4a0326ed5d4..1fe184f547a 100644
--- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts
+++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts
@@ -30,13 +30,6 @@ configurationRegistry.registerConfiguration({
title: nls.localize('chatAgentHostConfigurationTitle', "Chat Agent Host"),
type: 'object',
properties: {
- 'chat.agents.copilotCli.hideExtensionHost': {
- type: 'boolean',
- description: nls.localize('chat.agents.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the Agents window picker."),
- default: true,
- tags: ['experimental'],
- experiment: { mode: 'startup' },
- },
'chat.editor.preferCopilotHarness': {
type: 'boolean',
description: nls.localize('chat.editor.preferCopilotHarness', "When enabled, prefers the Agent Host Copilot CLI for new editor chat sessions. If the local harness is selected, it is replaced with Copilot once."),
@@ -58,12 +51,5 @@ configurationRegistry.registerConfiguration({
tags: ['experimental'],
experiment: { mode: 'startup' },
},
- 'chat.editor.copilotCli.hideExtensionHost': {
- type: 'boolean',
- description: nls.localize('chat.editor.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the editor window chat picker."),
- default: true,
- tags: ['experimental'],
- experiment: { mode: 'startup' },
- },
}
});
diff --git a/src/vs/platform/agentHost/common/agentHostProcessTelemetry.ts b/src/vs/platform/agentHost/common/agentHostProcessTelemetry.ts
index 7b180d3d76d..29fb044f75b 100644
--- a/src/vs/platform/agentHost/common/agentHostProcessTelemetry.ts
+++ b/src/vs/platform/agentHost/common/agentHostProcessTelemetry.ts
@@ -5,8 +5,10 @@
import { packErrorForTelemetry } from '../../telemetry/common/errorTelemetry.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
+import { AgentHostLaunchKind } from './agentHostTelemetry.js';
export type AgentHostProcessErrorData = {
+ hostLaunchKind: AgentHostLaunchKind;
kind: 'unexpectedExit' | 'startFailed';
code?: number;
restartCount: number;
@@ -20,6 +22,7 @@ type AgentHostProcessErrorEvent = AgentHostProcessErrorData & {
};
type AgentHostProcessErrorClassification = {
+ hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' };
kind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of agent host process failure.' };
code?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The agent host process exit code, when available.' };
restartCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The number of agent host restart attempts before this failure.' };
diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts
new file mode 100644
index 00000000000..857d439592f
--- /dev/null
+++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts
@@ -0,0 +1,81 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import type { AgentHostClientType } from './agentHostClientInfo.js';
+
+export const enum AgentHostLaunchKind {
+ VSCodeMainProcess = 'vscode_main_process',
+ VSCodeCLI = 'vscode_cli',
+ Unknown = 'unknown',
+}
+
+export const AgentHostLaunchKindEnvVar = 'VSCODE_AGENT_HOST_LAUNCH_KIND';
+
+export const enum AgentHostClientConnectionKind {
+ Local = 'local',
+ DirectWebSocket = 'direct_websocket',
+ DevTunnel = 'dev_tunnel',
+ SSH = 'ssh',
+ WSL = 'wsl',
+ RemoteExtensionHost = 'remote_extension_host',
+ WebPubSub = 'web_pub_sub',
+ Unknown = 'unknown',
+}
+
+export const enum AgentHostTransportKind {
+ MessagePort = 'message_port',
+ WebSocket = 'websocket',
+ Unknown = 'unknown',
+}
+
+export interface IAgentHostClientTelemetryContext {
+ readonly clientType: AgentHostClientType;
+ readonly connectionKind: AgentHostClientConnectionKind;
+ readonly transportKind: AgentHostTransportKind;
+ readonly hostLaunchKind: AgentHostLaunchKind;
+}
+
+export function createUnknownAgentHostClientTelemetryContext(clientType: AgentHostClientType): IAgentHostClientTelemetryContext {
+ return {
+ clientType,
+ connectionKind: AgentHostClientConnectionKind.Unknown,
+ transportKind: AgentHostTransportKind.Unknown,
+ hostLaunchKind: AgentHostLaunchKind.Unknown,
+ };
+}
+
+const CLIENT_CONNECTION_KIND_META_KEY = 'vscode.clientConnectionKind';
+
+export function toClientConnectionTelemetryMeta(connectionKind: AgentHostClientConnectionKind | undefined): Record | undefined {
+ return connectionKind === undefined || connectionKind === AgentHostClientConnectionKind.Unknown
+ ? undefined
+ : { [CLIENT_CONNECTION_KIND_META_KEY]: connectionKind };
+}
+
+export function readClientConnectionKind(meta: Record | undefined): AgentHostClientConnectionKind {
+ const value = meta?.[CLIENT_CONNECTION_KIND_META_KEY];
+ switch (value) {
+ case AgentHostClientConnectionKind.Local:
+ case AgentHostClientConnectionKind.DirectWebSocket:
+ case AgentHostClientConnectionKind.DevTunnel:
+ case AgentHostClientConnectionKind.SSH:
+ case AgentHostClientConnectionKind.WSL:
+ case AgentHostClientConnectionKind.RemoteExtensionHost:
+ case AgentHostClientConnectionKind.WebPubSub:
+ return value;
+ default:
+ return AgentHostClientConnectionKind.Unknown;
+ }
+}
+
+export function readAgentHostLaunchKind(value: string | undefined): AgentHostLaunchKind {
+ switch (value) {
+ case AgentHostLaunchKind.VSCodeMainProcess:
+ case AgentHostLaunchKind.VSCodeCLI:
+ return value;
+ default:
+ return AgentHostLaunchKind.Unknown;
+ }
+}
diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts
index 9d690a7184c..772df777d60 100644
--- a/src/vs/platform/agentHost/common/agentService.ts
+++ b/src/vs/platform/agentHost/common/agentService.ts
@@ -16,6 +16,7 @@ import type { IAgentServerToolHost } from './agentServerTools.js';
import type { IActiveSubscriptionInfo, IAgentSubscription } from './state/agentSubscription.js';
import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js';
import type { AgentHostClientType } from './agentHostClientInfo.js';
+import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js';
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js';
import type { InitializeResult } from './state/protocol/common/commands.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js';
@@ -2131,7 +2132,7 @@ export interface IAgentService {
* rather than {@link URI} objects so that authority-less scheme URIs
* like `ahp-root://` survive the wire format without normalization.
*/
- dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientType?: AgentHostClientType): void;
+ dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void;
/**
* List the contents of a directory on the agent host's filesystem.
diff --git a/src/vs/platform/agentHost/common/relayTransport.ts b/src/vs/platform/agentHost/common/relayTransport.ts
index 8773b71f971..16a6a3e5b55 100644
--- a/src/vs/platform/agentHost/common/relayTransport.ts
+++ b/src/vs/platform/agentHost/common/relayTransport.ts
@@ -6,6 +6,7 @@
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
+import { AgentHostClientConnectionKind } from './agentHostTelemetry.js';
import { AhpJsonlLogger, getAhpLogByteLength } from './ahpJsonlLogger.js';
import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from './state/sessionProtocol.js';
import type { IProtocolTransport } from './state/sessionTransport.js';
@@ -53,6 +54,7 @@ export class RelayTransport extends Disposable implements IProtocolTransport {
private readonly _ahpLogger: AhpJsonlLogger | undefined,
private readonly _logService: ILogService,
private readonly _logPrefix: string,
+ readonly clientConnectionKind: AgentHostClientConnectionKind,
) {
super();
if (this._ahpLogger) {
diff --git a/src/vs/platform/agentHost/common/sshConfigParsing.ts b/src/vs/platform/agentHost/common/sshConfigParsing.ts
index 1dae0a2d17c..21ab388453c 100644
--- a/src/vs/platform/agentHost/common/sshConfigParsing.ts
+++ b/src/vs/platform/agentHost/common/sshConfigParsing.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import type { ISSHResolvedConfig } from './sshRemoteAgentHost.js';
+import { isSSHStrictHostKeyChecking, type ISSHResolvedConfig } from './sshRemoteAgentHost.js';
/** Strip inline comments from an SSH config value. */
export function stripSSHComment(s: string): string {
@@ -34,6 +34,24 @@ export function parseSSHConfigHostEntries(content: string): string[] {
return hosts;
}
+/**
+ * Split a space-separated `ssh -G` path list, honoring double quotes so paths
+ * containing spaces survive. `ssh -G` emits `userknownhostsfile` and
+ * `globalknownhostsfile` as one line holding several paths.
+ */
+function parseSSHPathList(value: string): string[] {
+ const paths: string[] = [];
+ const pattern = /"([^"]*)"|(\S+)/g;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(value)) !== null) {
+ const path = match[1] ?? match[2];
+ if (path) {
+ paths.push(path);
+ }
+ }
+ return paths;
+}
+
/**
* Parse `ssh -G` output into a resolved config object.
*/
@@ -54,6 +72,8 @@ export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
}
}
+ const strictHostKeyChecking = map.get('stricthostkeychecking')?.toLowerCase();
+
return {
hostname: map.get('hostname') ?? '',
user: map.get('user') || undefined,
@@ -61,5 +81,10 @@ export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
identityFile: identityFiles,
identityAgent: map.get('identityagent') || undefined,
forwardAgent: map.get('forwardagent') === 'yes',
+ userKnownHostsFiles: parseSSHPathList(map.get('userknownhostsfile') ?? ''),
+ globalKnownHostsFiles: parseSSHPathList(map.get('globalknownhostsfile') ?? ''),
+ strictHostKeyChecking: strictHostKeyChecking && isSSHStrictHostKeyChecking(strictHostKeyChecking)
+ ? strictHostKeyChecking
+ : undefined,
};
}
diff --git a/src/vs/platform/agentHost/common/sshHostKeyPolicy.ts b/src/vs/platform/agentHost/common/sshHostKeyPolicy.ts
new file mode 100644
index 00000000000..9e27b072885
--- /dev/null
+++ b/src/vs/platform/agentHost/common/sshHostKeyPolicy.ts
@@ -0,0 +1,120 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import type { ISSHHostKeyVerificationRequest } from './sshRemoteAgentHost.js';
+import type { ISSHTrustedHostKey } from './sshHostKeyTrust.js';
+
+/**
+ * Refuse without offering a way through. Used for a changed or revoked key: an
+ * explicit "forget this host" step is required to recover, so a possible
+ * impersonation can never be waved away with one reflexive click.
+ *
+ * For a mismatch, `source` records where the disagreement came from, because
+ * it decides whether forgetting our stored key can actually unblock the user —
+ * a `known_hosts` verdict is not ours to clear.
+ */
+export type SSHHostKeyDenial =
+ | { readonly kind: 'deny'; readonly reason: 'mismatch'; readonly source: 'stored' | 'known-hosts' }
+ | { readonly kind: 'deny'; readonly reason: 'revoked' | 'strict-yes' | 'not-user-initiated' };
+
+/**
+ * What should happen with a presented host key, once the trust store and the
+ * user's `known_hosts` files have both been consulted.
+ */
+export type SSHHostKeyDecision =
+ /** Trust silently. No UI. */
+ | { readonly kind: 'trust'; readonly persist: boolean; readonly reason: 'stored' | 'known-hosts' | 'strict-accept-new' | 'strict-disabled' }
+ | SSHHostKeyDenial
+ /** Ask the user, then persist if they accept. */
+ | { readonly kind: 'prompt'; readonly reason: 'unknown' | 'ca-only' };
+
+/**
+ * Apply the host key trust policy.
+ *
+ * Pure so the whole matrix can be tested directly; the caller owns the UI and
+ * the storage writes. Ordering matters and is deliberate:
+ *
+ * 1. Revocation beats every other signal, including a stored trust entry and
+ * the `StrictHostKeyChecking` opt-out.
+ * 2. `StrictHostKeyChecking no`/`off` then accepts *unknown* keys, because the
+ * user has explicitly opted out of verification in their SSH config. We
+ * honor that but never persist, so turning it back on restores prompting.
+ * It does not extend to a key that contradicts one we already trust — see
+ * the note on that branch.
+ * 3. A key that disagrees with one we already trust is a mismatch even if
+ * `known_hosts` happens to agree with the server, since our store is the
+ * authority for hosts we have connected to before.
+ */
+export function decideHostKeyTrust(
+ request: ISSHHostKeyVerificationRequest,
+ trustedKeys: readonly ISSHTrustedHostKey[],
+): SSHHostKeyDecision {
+ const strict = request.strictHostKeyChecking;
+
+ // Revocation is checked before everything, including the
+ // `StrictHostKeyChecking` opt-out. Verified against OpenSSH 9.9: with
+ // `StrictHostKeyChecking=no` it still reports "REVOKED HOST KEY DETECTED"
+ // and disables password auth, keyboard-interactive auth and agent
+ // forwarding. Disabling host key checking means "I accept unknown keys",
+ // never "I accept keys I have explicitly revoked".
+ if (request.knownHostsMatch === 'revoked') {
+ return { kind: 'deny', reason: 'revoked' };
+ }
+
+ if (strict === 'no' || strict === 'off') {
+ // The opt-out covers *unknown* keys, not a key that disagrees with one
+ // we already trust. Verified against OpenSSH 9.9: with
+ // `StrictHostKeyChecking=no` and a changed host key it still prints
+ // "REMOTE HOST IDENTIFICATION HAS CHANGED!" and then disables password
+ // authentication, keyboard-interactive authentication and agent
+ // forwarding — precisely the paths that would hand credentials or agent
+ // access to a possible impostor.
+ //
+ // We refuse outright instead of connecting under those restrictions.
+ // That is stricter than OpenSSH, which still permits a signature-based
+ // (public key) login, but it matches the hard-fail contract a changed
+ // key gets everywhere else here, and recovery is the same explicit
+ // "forget this host" step.
+ const storedUnderOptOut = trustedKeys.find(key => key.keyType === request.keyType);
+ if (storedUnderOptOut && storedUnderOptOut.fingerprint !== request.fingerprint) {
+ return { kind: 'deny', reason: 'mismatch', source: 'stored' };
+ }
+ if (request.knownHostsMatch === 'mismatch') {
+ return { kind: 'deny', reason: 'mismatch', source: 'known-hosts' };
+ }
+ return { kind: 'trust', persist: false, reason: 'strict-disabled' };
+ }
+
+ const storedForKeyType = trustedKeys.find(key => key.keyType === request.keyType);
+ if (storedForKeyType) {
+ return storedForKeyType.fingerprint === request.fingerprint
+ ? { kind: 'trust', persist: false, reason: 'stored' }
+ : { kind: 'deny', reason: 'mismatch', source: 'stored' };
+ }
+
+ if (request.knownHostsMatch === 'mismatch') {
+ return { kind: 'deny', reason: 'mismatch', source: 'known-hosts' };
+ }
+
+ if (request.knownHostsMatch === 'match') {
+ // Copy into our own store so subsequent decisions do not depend on
+ // re-reading the user's files.
+ return { kind: 'trust', persist: true, reason: 'known-hosts' };
+ }
+
+ // Unknown (or CA-only, which we cannot validate — see below).
+ if (strict === 'yes') {
+ return { kind: 'deny', reason: 'strict-yes' };
+ }
+ if (strict === 'accept-new') {
+ return { kind: 'trust', persist: true, reason: 'strict-accept-new' };
+ }
+ if (!request.userInitiated) {
+ // A background reconnect must never raise a modal the user did not ask
+ // for, and silently trusting an unknown key would defeat the point.
+ return { kind: 'deny', reason: 'not-user-initiated' };
+ }
+ return { kind: 'prompt', reason: request.knownHostsMatch === 'ca-only' ? 'ca-only' : 'unknown' };
+}
diff --git a/src/vs/platform/agentHost/common/sshHostKeyTrust.ts b/src/vs/platform/agentHost/common/sshHostKeyTrust.ts
new file mode 100644
index 00000000000..183545f8caf
--- /dev/null
+++ b/src/vs/platform/agentHost/common/sshHostKeyTrust.ts
@@ -0,0 +1,71 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Event } from '../../../base/common/event.js';
+import { createDecorator } from '../../instantiation/common/instantiation.js';
+
+/**
+ * One host key the user has accepted for a remote, identified by its
+ * OpenSSH-style `SHA256:` fingerprint.
+ */
+export interface ISSHTrustedHostKey {
+ /** Host key algorithm, e.g. `ssh-ed25519`. */
+ readonly keyType: string;
+ /** `SHA256:...` fingerprint, matching `ssh-keygen -lf`. */
+ readonly fingerprint: string;
+ /** When this key was first trusted, as epoch milliseconds. */
+ readonly addedAt: number;
+ /** SSH config alias this host was reached through, for display. */
+ readonly alias?: string;
+}
+
+/** All trusted keys for a single host, keyed by `hostname:port`. */
+export interface ISSHTrustedHost {
+ readonly host: string;
+ readonly port: number;
+ readonly keys: readonly ISSHTrustedHostKey[];
+}
+
+/**
+ * Build the stable trust-store key for a host. Uses the resolved hostname and
+ * port rather than an SSH config alias, since several aliases can point at one
+ * machine and the host key belongs to the machine.
+ */
+export function computeHostKeyStoreKey(host: string, port: number): string {
+ return `${host.toLowerCase()}:${port}`;
+}
+
+export const ISSHHostKeyTrustService = createDecorator('sshHostKeyTrustService');
+
+/**
+ * Stores the SSH host keys the user has accepted for remote agent hosts.
+ *
+ * This is deliberately *our own* store rather than `~/.ssh/known_hosts`: we
+ * read the user's `known_hosts` files as an additional trust source (so anyone
+ * who already reached a machine from a terminal is not prompted again), but we
+ * never write to them. Nothing here should ever modify the user's SSH setup.
+ */
+export interface ISSHHostKeyTrustService {
+ readonly _serviceBrand: undefined;
+
+ /** Fires with the `hostname:port` key whose trusted set changed. */
+ readonly onDidChangeTrustedHosts: Event;
+
+ /** Trusted keys for a host, or an empty array when none are stored. */
+ getTrustedKeys(host: string, port: number): readonly ISSHTrustedHostKey[];
+
+ /**
+ * Record a host key as trusted. Replaces any existing entry for the same
+ * key type, so a key learned through rotation supersedes its predecessor
+ * rather than accumulating alongside it.
+ */
+ trustHostKey(host: string, port: number, key: ISSHTrustedHostKey): void;
+
+ /** Drop all trusted keys for a host. */
+ forgetHost(host: string, port: number): void;
+
+ /** Every host with at least one trusted key, for the "forget" picker. */
+ listTrustedHosts(): readonly ISSHTrustedHost[];
+}
diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
index 657322db1d4..8ea23146e5a 100644
--- a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
+++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
@@ -254,6 +254,19 @@ export interface ISSHConnectResult {
readonly lifecycle?: SSHAgentHostLifecycle;
}
+/**
+ * How OpenSSH should react to an unknown or changed host key, as reported by
+ * `ssh -G` (`stricthostkeychecking`). We honor the user's real SSH config here
+ * rather than introducing a parallel VS Code setting, so the escape hatch for
+ * users who genuinely cannot use verification stays where they expect it.
+ */
+export type SSHStrictHostKeyChecking = 'ask' | 'accept-new' | 'yes' | 'no' | 'off';
+
+/** Narrow an arbitrary `ssh -G` value to a {@link SSHStrictHostKeyChecking}. */
+export function isSSHStrictHostKeyChecking(value: string): value is SSHStrictHostKeyChecking {
+ return value === 'ask' || value === 'accept-new' || value === 'yes' || value === 'no' || value === 'off';
+}
+
/**
* Resolved SSH configuration for a host, obtained from `ssh -G`.
*/
@@ -264,6 +277,16 @@ export interface ISSHResolvedConfig {
readonly identityFile: string[];
readonly identityAgent: string | undefined;
readonly forwardAgent: boolean;
+ /**
+ * `UserKnownHostsFile` paths, in priority order. `ssh -G` emits these as a
+ * single space-separated list, so this is already split. Typically
+ * `~/.ssh/known_hosts` and `~/.ssh/known_hosts2`.
+ */
+ readonly userKnownHostsFiles: string[];
+ /** `GlobalKnownHostsFile` paths, e.g. `/etc/ssh/ssh_known_hosts`. */
+ readonly globalKnownHostsFiles: string[];
+ /** Resolved `StrictHostKeyChecking`, when it is a value we recognize. */
+ readonly strictHostKeyChecking: SSHStrictHostKeyChecking | undefined;
}
export interface ISSHConnectProgress {
@@ -344,6 +367,97 @@ export type ISSHEndpointSelection =
| { readonly kind: 'candidate'; readonly type: AgentHostServerType; readonly pid: number; readonly instanceId: string }
| { readonly kind: 'spawn' };
+/**
+ * What the user's `known_hosts` files say about a presented host key. Mirrors
+ * `KnownHostsMatch` in `../node/sshKnownHosts.js`, redeclared here because
+ * this common-layer module cannot import from `node`.
+ */
+export type SSHKnownHostsMatch = 'match' | 'mismatch' | 'revoked' | 'ca-only' | 'unknown';
+
+/**
+ * Error name for a connect attempt refused because the server's host key was
+ * not trusted. Matching on the name (rather than `instanceof`) is deliberate:
+ * the error is raised in the shared process and inspected in the renderer, and
+ * only `name`/`message` survive IPC serialization.
+ */
+export const SSH_HOST_KEY_DENIED_ERROR_NAME = 'SSHHostKeyDenied';
+
+/**
+ * Raised when host key verification refused the connection.
+ *
+ * The host key UI owns the conversation about *why* — either the user
+ * declined the prompt themselves, or a specific, actionable notification
+ * (with a "Forget Saved Host Key" action) is already on screen. Callers should
+ * therefore not add a generic "failed to connect" error on top; see
+ * {@link isSSHHostKeyDeniedError}.
+ */
+export class SSHHostKeyDeniedError extends Error {
+ constructor(displayHost: string) {
+ super(`Host key verification failed for ${displayHost}`);
+ this.name = SSH_HOST_KEY_DENIED_ERROR_NAME;
+ }
+}
+
+/** Whether `error` is an {@link SSHHostKeyDeniedError}, including across IPC. */
+export function isSSHHostKeyDeniedError(error: unknown): boolean {
+ return error instanceof Error && error.name === SSH_HOST_KEY_DENIED_ERROR_NAME;
+}
+
+/**
+ * Request from the shared process for the renderer to decide whether a
+ * server's host key should be trusted. Fired from ssh2's `hostVerifier` during
+ * key exchange — that is, *before* authentication — so declining guarantees no
+ * password or SSH agent access is ever exposed to an unverified server.
+ *
+ * The shared process only gathers evidence ({@link knownHostsMatch} and the
+ * fingerprint); the renderer owns the actual policy, since it holds the trust
+ * store and the UI. The renderer must answer via
+ * {@link ISSHRemoteAgentHostMainService.respondHostKeyVerification} with the
+ * same `requestId`, otherwise the connection stalls until the deadline.
+ *
+ * (`ISSHRemoteAgentHostMainService` is a misnomer inherited from its siblings:
+ * it and the WSL/tunnel equivalents are all registered in `sharedProcessMain`,
+ * so they run in the shared process, not the main process.)
+ */
+export interface ISSHHostKeyVerificationRequest {
+ readonly requestId: string;
+ readonly connectionKey: string;
+ /** Display-friendly host (e.g. SSH config alias or `user@host`). */
+ readonly displayHost: string;
+ /** Resolved hostname the key was presented for. */
+ readonly host: string;
+ readonly port: number;
+ /** Host key algorithm, e.g. `ssh-ed25519`. */
+ readonly keyType: string;
+ /** OpenSSH-style `SHA256:...` fingerprint, matching `ssh-keygen -lf`. */
+ readonly fingerprint: string;
+ /** What the user's `known_hosts` files say about this key. */
+ readonly knownHostsMatch: SSHKnownHostsMatch;
+ /** Resolved `StrictHostKeyChecking` from `ssh -G`, when recognized. */
+ readonly strictHostKeyChecking?: SSHStrictHostKeyChecking;
+ /**
+ * Whether the owning connect attempt was directly requested by the user.
+ * Background reconnects must never open a modal, so an unknown host key on
+ * a silent reconnect is declined rather than prompted for.
+ */
+ readonly userInitiated: boolean;
+}
+
+/**
+ * A host key proven to belong to an already-authenticated server, delivered
+ * via OpenSSH's `UpdateHostKeys` extension (`hostkeys-00@openssh.com`). ssh2
+ * completes the `hostkeys-prove-00@openssh.com` challenge and verifies the
+ * signatures before surfacing these, so they can be trusted without prompting
+ * — this is what lets a legitimate server key rotation be picked up silently
+ * instead of surfacing as a scary mismatch.
+ */
+export interface ISSHHostKeysAnnouncement {
+ readonly connectionKey: string;
+ readonly host: string;
+ readonly port: number;
+ readonly keys: readonly { readonly keyType: string; readonly fingerprint: string }[];
+}
+
/**
* Main-process service that performs the actual SSH work.
* The renderer calls this over IPC and handles registration
@@ -373,7 +487,7 @@ export interface ISSHRemoteAgentHostMainService {
* Fires when the SSH server requests keyboard-interactive auth (typically
* a password prompt). The renderer must answer via {@link respondKeyboardInteractive}
* with the same `requestId`, otherwise the auth attempt will hang until the
- * SSH `readyTimeout` elapses.
+ * SSH handshake deadline elapses.
*/
readonly onDidRequestKeyboardInteractive: Event;
@@ -413,6 +527,36 @@ export interface ISSHRemoteAgentHostMainService {
*/
respondEndpointSelection(requestId: string, selection: ISSHEndpointSelection | undefined): Promise;
+ /**
+ * Fires when a server presents a host key during key exchange and the
+ * renderer must decide whether to trust it. Answering is mandatory: until
+ * {@link respondHostKeyVerification} is called with the same `requestId`,
+ * the SSH handshake is suspended.
+ */
+ readonly onDidRequestHostKeyVerification: Event;
+
+ /**
+ * Fires when a previously requested host key verification is no longer
+ * needed (e.g. the owning connect attempt failed or was aborted). The
+ * renderer should dismiss any UI it opened for `requestId`.
+ */
+ readonly onDidCancelHostKeyVerification: Event;
+
+ /**
+ * Provide the user's trust decision for a previously fired host key
+ * verification request. Passing `false` fails the key exchange, which
+ * tears the connection down before any authentication is attempted.
+ */
+ respondHostKeyVerification(requestId: string, trusted: boolean): Promise;
+
+ /**
+ * Fires when a server announces its full set of host keys over an
+ * already-authenticated connection. See {@link ISSHHostKeysAnnouncement} —
+ * these keys are cryptographically proven, so consumers can persist them
+ * without prompting.
+ */
+ readonly onDidAnnounceHostKeys: Event;
+
/**
* Bootstrap a remote agent host over SSH. Returns serializable
* connection info for the renderer to register.
diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts
index 83504b830a5..511239baa4e 100644
--- a/src/vs/platform/agentHost/common/state/sessionTransport.ts
+++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts
@@ -12,6 +12,7 @@
import { Event } from '../../../../base/common/event.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
+import type { AgentHostClientConnectionKind, AgentHostTransportKind } from '../agentHostTelemetry.js';
import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcResponse, JsonRpcRequest } from './sessionProtocol.js';
/**
@@ -19,6 +20,12 @@ import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonR
* serialization, framing, and connection management.
*/
export interface IProtocolTransport extends IDisposable {
+ /** Physical transport accepted by the agent host. */
+ readonly transportKind?: AgentHostTransportKind;
+
+ /** Route used by a VS Code client to reach the agent host. */
+ readonly clientConnectionKind?: AgentHostClientConnectionKind;
+
/** Fires when a message is received from the remote end. */
readonly onMessage: Event;
diff --git a/src/vs/platform/agentHost/common/tunnelAgentHost.ts b/src/vs/platform/agentHost/common/tunnelAgentHost.ts
index 2a4f07b36ea..ca8f70170cd 100644
--- a/src/vs/platform/agentHost/common/tunnelAgentHost.ts
+++ b/src/vs/platform/agentHost/common/tunnelAgentHost.ts
@@ -262,6 +262,34 @@ export function parseTunnelGatewaySelectionResponse(json: string): { ok: true; s
};
}
+/**
+ * `Error.name` carried by the failure {@link ITunnelAgentHostMainService.completeSelection}
+ * throws when the gateway itself answered `{"ok":false}` — i.e. the tunnel
+ * relay is up and reachable, and only the endpoint we picked turned out to
+ * be gone (its registry entry vanished, or its socket/port could not be
+ * dialed). Callers must distinguish this from a transport failure: a
+ * transport failure means the tunnel is down and the same destination
+ * should simply be retried, whereas a rejection means retrying the same
+ * endpoint can never succeed and a different one has to be selected.
+ *
+ * Modelled as a name rather than an `Error` subclass because this crosses
+ * the shared-process IPC boundary, which preserves `name`/`message`/`stack`
+ * but not the prototype chain.
+ */
+export const TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME = 'TunnelGatewaySelectionRejectedError';
+
+/** Creates the error described by {@link TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME}. */
+export function createTunnelGatewaySelectionRejectedError(message: string): Error {
+ const error = new Error(message);
+ error.name = TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME;
+ return error;
+}
+
+/** Whether `error` is a gateway rejection, including one received over IPC. See {@link TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME}. */
+export function isTunnelGatewaySelectionRejectedError(error: unknown): boolean {
+ return error instanceof Error && error.name === TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME;
+}
+
/**
* Serializable result from a successful tunnel connect operation.
* Returned over IPC from the shared process.
@@ -362,6 +390,12 @@ export interface ITunnelAgentHostMainService {
* sends the selection message over the pending gateway WebSocket, awaits
* its ready acknowledgement, and registers the resulting relay
* connection the same way {@link connect} does.
+ *
+ * Rejects with an error named {@link TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME}
+ * when the gateway answered but refused the selection, and with any
+ * other error when the tunnel transport itself failed. Either way the
+ * pending session is consumed and disposed, so retrying requires a fresh
+ * {@link prepareSelection}.
*/
completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise;
diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts
index 92784a24811..3403e2db39a 100644
--- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts
+++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts
@@ -25,6 +25,7 @@ import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from
import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js';
import { IAgentHostEnablementService } from '../common/agentHostEnablementService.js';
import { LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import {
AgentHostAhpJsonlLoggingSettingId,
AgentHostByokModelsEnabledSettingId,
@@ -125,6 +126,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos
const transport = new AgentHostIpcChannelTransport(
getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.Protocol))),
this._ahpLogger,
+ AgentHostClientConnectionKind.Local,
);
this._protocolClient = this._register(this._instantiationService.createInstance(
RemoteAgentHostProtocolClient,
diff --git a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts
index ce4caf4dfc9..fe078e7398e 100644
--- a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts
+++ b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts
@@ -5,6 +5,7 @@
import { ILogService } from '../../log/common/log.js';
import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import { RelayTransport } from '../common/relayTransport.js';
import type { ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js';
@@ -15,6 +16,6 @@ export class SSHRelayTransport extends RelayTransport {
ahpLogger: AhpJsonlLogger | undefined,
@ILogService logService: ILogService,
) {
- super(connectionId, sshService, ahpLogger, logService, '[SSHRelayTransport]');
+ super(connectionId, sshService, ahpLogger, logService, '[SSHRelayTransport]', AgentHostClientConnectionKind.SSH);
}
}
diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts
index d8b5b491bb0..72337395c3a 100644
--- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts
+++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts
@@ -5,6 +5,7 @@
import { Emitter, Event } from '../../../base/common/event.js';
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
+import { Codicon } from '../../../base/common/codicons.js';
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
@@ -12,7 +13,8 @@ import { ILogService } from '../../log/common/log.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { IDialogService } from '../../dialogs/common/dialogs.js';
import { IEnvironmentService } from '../../environment/common/environment.js';
-import { INotificationService } from '../../notification/common/notification.js';
+import { INotificationService, Severity } from '../../notification/common/notification.js';
+import { toAction } from '../../../base/common/actions.js';
import { IProductService } from '../../product/common/productService.js';
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
@@ -38,11 +40,33 @@ import {
type ISSHEndpointCandidate,
type ISSHEndpointSelection,
type ISSHEndpointSelectionRequest,
+ type ISSHHostKeyVerificationRequest,
+ type ISSHHostKeysAnnouncement,
type ISSHKeyboardInteractiveRequest,
type ISSHRemoteAgentHostMainService,
type ISSHResolvedConfig,
type ISSHConnectProgress,
} from '../common/sshRemoteAgentHost.js';
+import { ISSHHostKeyTrustService } from '../common/sshHostKeyTrust.js';
+import { decideHostKeyTrust, type SSHHostKeyDenial } from '../common/sshHostKeyPolicy.js';
+
+/**
+ * Human-readable name for a host key algorithm, matching how OpenSSH labels
+ * them in its own prompts (e.g. "ED25519 key fingerprint is ...").
+ */
+export function describeHostKeyType(keyType: string): string {
+ switch (keyType) {
+ case 'ssh-ed25519': return 'ED25519';
+ case 'ssh-rsa':
+ case 'rsa-sha2-256':
+ case 'rsa-sha2-512': return 'RSA';
+ case 'ssh-dss': return 'DSA';
+ case 'ecdsa-sha2-nistp256':
+ case 'ecdsa-sha2-nistp384':
+ case 'ecdsa-sha2-nistp521': return 'ECDSA';
+ default: return keyType;
+ }
+}
export const ISSHRelayClientFactory = createDecorator('sshRelayClientFactory');
@@ -99,6 +123,14 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
*/
private readonly _lastConnectedServerTypeByAddress = new Map();
+ /**
+ * The host key that authenticated the most recent session for a given
+ * connection key. Used to decide whether an `UpdateHostKeys` announcement
+ * may be trusted (see {@link _handleAnnouncedHostKeys}). Bounded by the
+ * number of distinct SSH hosts, and each entry is overwritten on reconnect.
+ */
+ private readonly _sessionHostKeys = new Map();
+
constructor(
@ISharedProcessService sharedProcessService: ISharedProcessService,
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@@ -110,6 +142,7 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
@IRemoteAgentHostLocationPreferenceService private readonly _locationPreferenceService: IRemoteAgentHostLocationPreferenceService,
@IDialogService private readonly _dialogService: IDialogService,
@IProductService private readonly _productService: IProductService,
+ @ISSHHostKeyTrustService private readonly _hostKeyTrustService: ISSHHostKeyTrustService,
) {
super();
@@ -162,6 +195,20 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
this._register(this._mainService.onDidRequestEndpointSelection(request => {
this._handleEndpointSelectionRequest(request);
}));
+
+ // Verify server host keys. Without this the shared process would accept
+ // any key from any server, so this is what actually makes SSH agent
+ // host connections resistant to impersonation.
+ this._register(this._mainService.onDidRequestHostKeyVerification(request => {
+ this._trackHostKeyVerification(this._handleHostKeyVerificationRequest(request));
+ }));
+
+ // Learn host keys a server proves it owns over an already-authenticated
+ // connection (OpenSSH's UpdateHostKeys), so a legitimate key rotation
+ // is picked up silently rather than becoming a hard failure later.
+ this._register(this._mainService.onDidAnnounceHostKeys(announcement => {
+ this._handleAnnouncedHostKeys(announcement);
+ }));
}
get connections(): readonly ISSHAgentHostConnection[] {
@@ -478,6 +525,234 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
}
}
+ /**
+ * Decide whether to trust a server's host key, and tell the shared process.
+ *
+ * Policy lives in {@link decideHostKeyTrust}; this method owns the UI and
+ * the storage writes. Every path must respond exactly once — the SSH
+ * handshake is suspended until it hears back.
+ */
+ /**
+ * Hook for observing when a host key verification has fully settled.
+ * Overridden by tests so they can await the real operation instead of
+ * sleeping for a fixed interval, which is load-dependent and flaky —
+ * particularly for the cases that assert *nothing* happened.
+ */
+ protected _trackHostKeyVerification(handled: Promise): void {
+ void handled;
+ }
+
+ private async _handleHostKeyVerificationRequest(request: ISSHHostKeyVerificationRequest): Promise {
+ this._logService.info(`[SSHRemoteAgentHost] Host key verification for ${request.displayHost}: ${request.keyType} ${request.fingerprint} (known_hosts: ${request.knownHostsMatch})`);
+
+ const cts = new CancellationTokenSource();
+ const cancelListener = this._mainService.onDidCancelHostKeyVerification(requestId => {
+ if (requestId === request.requestId) {
+ cts.cancel();
+ }
+ });
+
+ try {
+ const decision = decideHostKeyTrust(request, this._hostKeyTrustService.getTrustedKeys(request.host, request.port));
+ this._logService.info(`[SSHRemoteAgentHost] Host key decision for ${request.displayHost}: ${decision.kind} (${decision.reason})`);
+
+ let trusted: boolean;
+ switch (decision.kind) {
+ case 'trust':
+ if (decision.persist) {
+ this._trustHostKey(request);
+ }
+ trusted = true;
+ break;
+ case 'deny':
+ this._reportHostKeyDenied(request, decision);
+ trusted = false;
+ break;
+ case 'prompt': {
+ trusted = await this._promptForHostKey(request, decision.reason, cts.token);
+ if (cts.token.isCancellationRequested) {
+ return;
+ }
+ if (trusted) {
+ this._trustHostKey(request);
+ }
+ break;
+ }
+ }
+
+ if (cts.token.isCancellationRequested) {
+ return;
+ }
+ // Remember which host key actually authenticated this session, so
+ // a later UpdateHostKeys announcement can be checked against it.
+ this._sessionHostKeys.set(request.connectionKey, { keyType: request.keyType, fingerprint: request.fingerprint });
+ await this._mainService.respondHostKeyVerification(request.requestId, trusted);
+ } catch (err) {
+ this._logService.error('[SSHRemoteAgentHost] Failed handling host key verification', err);
+ // Fail closed: an error here must never become a way to connect to
+ // an unverified server.
+ try {
+ await this._mainService.respondHostKeyVerification(request.requestId, false);
+ } catch { /* swallow */ }
+ } finally {
+ cancelListener.dispose();
+ cts.dispose();
+ }
+ }
+
+ private _trustHostKey(request: ISSHHostKeyVerificationRequest): void {
+ this._hostKeyTrustService.trustHostKey(request.host, request.port, {
+ keyType: request.keyType,
+ fingerprint: request.fingerprint,
+ addedAt: Date.now(),
+ ...(request.displayHost !== request.host ? { alias: request.displayHost } : undefined),
+ });
+ }
+
+ /**
+ * Ask the user whether to trust an unrecognized host key, echoing OpenSSH's
+ * wording so it is recognizable to anyone who has used `ssh` directly.
+ * Cancel is the default so the safe answer is the one you get by dismissing.
+ *
+ * Uses a custom dialog so the prompt can be dismissed programmatically when
+ * the connection dies underneath it — a native dialog cannot be, and would
+ * strand the user with a question about a connection that no longer exists.
+ * Answering a stale prompt was always safe (the caller re-checks
+ * cancellation before acting), but leaving it on screen is confusing.
+ */
+ private async _promptForHostKey(request: ISSHHostKeyVerificationRequest, reason: 'unknown' | 'ca-only', token: CancellationToken): Promise {
+ if (token.isCancellationRequested) {
+ return false;
+ }
+
+ const detail = reason === 'ca-only'
+ ? localize(
+ 'sshHostKeyCaOnlyDetail',
+ "{0} key fingerprint is {1}.\n\nThis host is configured to use a certificate authority, but certificate-based host keys cannot be verified here, so this key cannot be checked against it.",
+ describeHostKeyType(request.keyType), request.fingerprint)
+ : localize(
+ 'sshHostKeyUnknownDetail',
+ "{0} key fingerprint is {1}.\n\nVerify this fingerprint matches the host before continuing.",
+ describeHostKeyType(request.keyType), request.fingerprint);
+
+ const { confirmed } = await this._dialogService.confirm({
+ type: 'warning',
+ message: localize('sshHostKeyUnknownMessage', "The authenticity of host '{0}' can't be established.", request.displayHost),
+ detail,
+ primaryButton: localize('sshHostKeyConnect', "&&Connect"),
+ cancelButton: localize('sshHostKeyCancel', "Cancel"),
+ custom: { icon: Codicon.shield },
+ // Cancellation resolves the dialog as if Cancel was pressed, which
+ // is also the answer we want for a connection that is already gone.
+ token,
+ });
+ return confirmed;
+ }
+
+ /**
+ * Explain a refusal. A changed or revoked key gets an error notification
+ * with no "trust anyway" affordance — recovering requires explicitly
+ * forgetting the host, so a possible impersonation cannot be dismissed
+ * with a single reflexive click.
+ */
+ private _reportHostKeyDenied(request: ISSHHostKeyVerificationRequest, denial: SSHHostKeyDenial): void {
+ if (denial.reason === 'not-user-initiated') {
+ // A background reconnect: log it, but do not interrupt with UI the
+ // user did not ask for. Connecting manually surfaces the prompt.
+ this._logService.warn(`[SSHRemoteAgentHost] Declining unknown host key for ${request.displayHost} during a background reconnect; connect manually to review it.`);
+ return;
+ }
+
+ if (denial.reason === 'strict-yes') {
+ this._notificationService.error(localize(
+ 'sshHostKeyStrictUnknown',
+ "Can't connect to '{0}': its host key is not known, and StrictHostKeyChecking is set to \"yes\" in your SSH configuration.",
+ request.displayHost));
+ return;
+ }
+
+ // Forgetting our stored key only helps when our store is what
+ // disagreed. A revoked marker, or a conflicting `known_hosts` entry,
+ // lives in the user's own files and would keep winning afterwards — so
+ // offering the action there would send them in circles.
+ if (denial.reason !== 'mismatch') { // 'revoked'
+ this._notificationService.error(localize(
+ 'sshHostKeyRevoked',
+ "Host key verification failed for '{0}'. This host's {1} key has been marked as revoked in your known_hosts file. Remove the @revoked line from known_hosts if this key should be trusted again.",
+ request.displayHost, describeHostKeyType(request.keyType)));
+ return;
+ }
+
+ if (denial.source === 'known-hosts') {
+ this._notificationService.error(localize(
+ 'sshHostKeyChangedKnownHosts',
+ "Host key verification failed for '{0}'. Its {1} host key does not match the entry in your known_hosts file, which could mean someone is impersonating the host — or that the host was legitimately rebuilt. Received {2}. Update or remove the known_hosts entry if this change was expected.",
+ request.displayHost, describeHostKeyType(request.keyType), request.fingerprint));
+ return;
+ }
+
+ this._notificationService.notify({
+ severity: Severity.Error,
+ message: localize(
+ 'sshHostKeyChanged',
+ "Host key verification failed for '{0}'. Its {1} host key has changed, which could mean someone is impersonating the host — or that the host was legitimately rebuilt. Received {2}.",
+ request.displayHost, describeHostKeyType(request.keyType), request.fingerprint),
+ actions: {
+ primary: [toAction({
+ id: 'sshHostKey.forget',
+ label: localize('sshHostKeyForgetAction', "Forget Saved Host Key"),
+ run: () => this._hostKeyTrustService.forgetHost(request.host, request.port),
+ })],
+ },
+ });
+ }
+
+ /**
+ * Persist host keys the server proved it owns, so a legitimate key
+ * rotation is invisible to the user instead of a hard failure on the next
+ * connect.
+ *
+ * ssh2 verifies the `hostkeys-prove` signatures before surfacing these,
+ * but that only proves the keys belong to *whoever we are currently
+ * talking to* — it says nothing about whether that party is the real host.
+ * So we additionally require that the host key which authenticated this
+ * very session is itself currently trusted. This mirrors OpenSSH, whose
+ * `UpdateHostKeys` documentation states additional host keys are accepted
+ * only "if the key used to authenticate the host was already trusted or
+ * explicitly accepted by the user".
+ *
+ * Without that check, a session accepted through
+ * `StrictHostKeyChecking=no` — where we deliberately did not verify
+ * anything — could announce keys that overwrite the user's genuine stored
+ * key, leaving an impostor's key trusted once strict checking is restored.
+ */
+ private _handleAnnouncedHostKeys(announcement: ISSHHostKeysAnnouncement): void {
+ const existing = this._hostKeyTrustService.getTrustedKeys(announcement.host, announcement.port);
+ if (!existing.length) {
+ // Only extend trust we already have. Recording keys for a host the
+ // user has never accepted would turn an announcement into a way to
+ // establish trust without any verification at all.
+ return;
+ }
+
+ const sessionKey = this._sessionHostKeys.get(announcement.connectionKey);
+ if (!sessionKey || !existing.some(e => e.keyType === sessionKey.keyType && e.fingerprint === sessionKey.fingerprint)) {
+ this._logService.warn(`[SSHRemoteAgentHost] Ignoring announced host keys for ${announcement.host}: the key that authenticated this session is not itself trusted`);
+ return;
+ }
+
+ for (const key of announcement.keys) {
+ if (!existing.some(e => e.keyType === key.keyType && e.fingerprint === key.fingerprint)) {
+ this._logService.info(`[SSHRemoteAgentHost] Learned rotated ${key.keyType} host key for ${announcement.host}: ${key.fingerprint}`);
+ this._hostKeyTrustService.trustHostKey(announcement.host, announcement.port, {
+ keyType: key.keyType,
+ fingerprint: key.fingerprint,
+ addedAt: Date.now(),
+ });
+ }
+ }
+ }
+
/**
* Resolve which live remote agent host endpoint (or "start a new one")
* to connect to and forward the choice (or cancellation) back to the
diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
index ee9ac4453fe..6f9c39f6713 100644
--- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
+++ b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
@@ -6,6 +6,7 @@
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { AhpJsonlLogger, getAhpLogByteLength } from '../common/ahpJsonlLogger.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js';
@@ -19,6 +20,7 @@ import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from
* and forwards messages bidirectionally through this IPC channel.
*/
export class TunnelRelayTransport extends Disposable implements IProtocolTransport {
+ readonly clientConnectionKind = AgentHostClientConnectionKind.DevTunnel;
private readonly _onMessage = this._register(new Emitter());
readonly onMessage = this._onMessage.event;
diff --git a/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts
index 88f21b8032e..dc7c5333af8 100644
--- a/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts
+++ b/src/vs/platform/agentHost/electron-browser/wslRelayTransport.ts
@@ -5,6 +5,7 @@
import { ILogService } from '../../log/common/log.js';
import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js';
+import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js';
import { RelayTransport } from '../common/relayTransport.js';
import type { IWSLRemoteAgentHostMainService } from '../common/wslRemoteAgentHost.js';
@@ -15,6 +16,6 @@ export class WSLRelayTransport extends RelayTransport {
ahpLogger: AhpJsonlLogger | undefined,
@ILogService logService: ILogService,
) {
- super(connectionId, wslService, ahpLogger, logService, '[WSLRelayTransport]');
+ super(connectionId, wslService, ahpLogger, logService, '[WSLRelayTransport]', AgentHostClientConnectionKind.WSL);
}
}
diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts
index f8ddfa5d3f8..81044407d5d 100644
--- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts
+++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts
@@ -21,6 +21,7 @@ import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js';
import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js';
import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js';
import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js';
+import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar } from '../common/agentHostTelemetry.js';
import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js';
import { deepClone } from '../../../base/common/objects.js';
import '../common/agentHostStarter.config.contribution.js';
@@ -157,6 +158,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt
VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain',
VSCODE_PIPE_LOGGING: 'true',
VSCODE_VERBOSE_LOGGING: 'true',
+ [AgentHostLaunchKindEnvVar]: AgentHostLaunchKind.VSCodeMainProcess,
...sdkEnv,
...otelEnv,
...telemetryIdEnv,
diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts
new file mode 100644
index 00000000000..be272c4aef3
--- /dev/null
+++ b/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Disposable } from '../../../base/common/lifecycle.js';
+
+export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10;
+
+export interface IAgentHostClientConnectionCounts {
+ readonly connectedClientCount: number;
+ readonly connectedTransportCount: number;
+ readonly clientTransportCount: number;
+}
+
+export interface IAgentHostClientConnectedResult extends IAgentHostClientConnectionCounts {
+ readonly isReconnect: boolean;
+}
+
+export class AgentHostClientConnectionTelemetryTracker extends Disposable {
+ private readonly _recentlyDisconnectedClients = new Map();
+ private readonly _activeTransports = new Map>();
+
+ constructor(private readonly _historyRetentionMs = AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION) {
+ super();
+ }
+
+ hasSeenClient(clientId: string): boolean {
+ this._pruneDisconnectedClientHistory();
+ return this._activeTransports.has(clientId) || this._recentlyDisconnectedClients.has(clientId);
+ }
+
+ connect(clientId: string, transportToken: object): IAgentHostClientConnectedResult {
+ const isReconnect = this.hasSeenClient(clientId);
+ this._recentlyDisconnectedClients.delete(clientId);
+ let transports = this._activeTransports.get(clientId);
+ if (!transports) {
+ transports = new Set();
+ this._activeTransports.set(clientId, transports);
+ }
+ transports.add(transportToken);
+ return { isReconnect, ...this._counts(clientId) };
+ }
+
+ disconnect(clientId: string, transportToken: object): IAgentHostClientConnectionCounts {
+ const transports = this._activeTransports.get(clientId);
+ transports?.delete(transportToken);
+ if (transports?.size === 0) {
+ this._activeTransports.delete(clientId);
+ this._recentlyDisconnectedClients.set(clientId, Date.now());
+ }
+ this._pruneDisconnectedClientHistory();
+ return this._counts(clientId);
+ }
+
+ override dispose(): void {
+ this._recentlyDisconnectedClients.clear();
+ this._activeTransports.clear();
+ super.dispose();
+ }
+
+ private _pruneDisconnectedClientHistory(): void {
+ const cutoff = Date.now() - this._historyRetentionMs;
+ for (const [clientId, disconnectedAt] of this._recentlyDisconnectedClients) {
+ if (disconnectedAt <= cutoff) {
+ this._recentlyDisconnectedClients.delete(clientId);
+ }
+ }
+ }
+
+ private _counts(clientId: string): IAgentHostClientConnectionCounts {
+ let connectedTransportCount = 0;
+ for (const transports of this._activeTransports.values()) {
+ connectedTransportCount += transports.size;
+ }
+ return {
+ connectedClientCount: this._activeTransports.size,
+ connectedTransportCount,
+ clientTransportCount: this._activeTransports.get(clientId)?.size ?? 0,
+ };
+ }
+}
diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts
index 5c43a64998c..cea1ff2b297 100644
--- a/src/vs/platform/agentHost/node/agentHostMain.ts
+++ b/src/vs/platform/agentHost/node/agentHostMain.ts
@@ -41,6 +41,7 @@ import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress
import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js';
import { AgentHostOTelService } from './otel/agentHostOTelService.js';
import { ProtocolServerHandler } from './protocolServerHandler.js';
+import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js';
import { WebSocketProtocolServer } from './webSocketTransport.js';
import { MessagePortProtocolServer } from './messagePortProtocolServer.js';
import { cleanupLocalAgentHostEndpointMetadataSync, cleanupLocalAgentHostEndpointSocketSync, createLocalAgentHostEndpointMetadata, prepareLocalAgentHostEndpointMetadataDirectory, prepareLocalAgentHostEndpointSocketDirectory, publishLocalAgentHostEndpointMetadata, type ILocalAgentHostEndpointMetadata } from './localAgentHostMetadata.js';
@@ -92,6 +93,7 @@ import { join } from '../../../base/common/path.js';
import { createAgentHostTelemetryService } from './agentHostTelemetryService.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js';
+import { AgentHostLaunchKindEnvVar, readAgentHostLaunchKind, type AgentHostLaunchKind } from '../common/agentHostTelemetry.js';
// Entry point for the agent host utility process.
// Sets up IPC, logging, and registers agent providers (Copilot).
@@ -161,6 +163,8 @@ async function startAgentHost(): Promise {
// renderer's BYOK server channel are not wired, so the registry stays empty
// and the proxy never binds.
const byokLmEnabled = isAgentEnabled(process.env[AgentHostByokModelsEnabledEnvVar], true);
+ const hostLaunchKind = readAgentHostLaunchKind(process.env[AgentHostLaunchKindEnvVar]);
+ const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker());
try {
// Build the process DI container and network stack before telemetry so every
// outbound fetch, including restricted telemetry, uses the same proxy resolver.
@@ -202,7 +206,7 @@ async function startAgentHost(): Promise {
diServices.set(IByokLmProxyService, byokLmProxyService);
const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn));
diServices.set(IAgentHostOTelService, agentHostOTelService);
- agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]);
+ agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], hostLaunchKind);
const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService);
diServices.set(INetworkDiagnosticsService, networkDiagnosticsService);
agentService.setNetworkDiagnosticsService(networkDiagnosticsService);
@@ -324,6 +328,8 @@ async function startAgentHost(): Promise {
// Shared config for the local data-plane protocol handlers (renderer
// MessagePort + the external endpoint, which each get their own handler).
const localProtocolHandlerConfig = {
+ hostLaunchKind,
+ connectionTelemetryTracker,
defaultDirectory: URI.file(os.homedir()).toString(),
completionTriggerCharacters: agentService.completionTriggerCharacters,
terminalCommandPrefix: BANG_COMMAND_PREFIX,
@@ -332,13 +338,13 @@ async function startAgentHost(): Promise {
};
try {
// Handler for the renderer's MessagePort data plane.
- localDataPlaneDisposables.add(new ProtocolServerHandler(
+ localDataPlaneDisposables.add(instantiationService.createInstance(
+ ProtocolServerHandler,
agentService,
agentService.stateManager,
messagePortProtocolServer,
localProtocolHandlerConfig,
clientFileSystemProvider,
- logService,
));
// Non-protocol reverse bridges remain on their existing IPC channels.
// The renderer's MessagePortClient ctx is its clientId.
@@ -407,13 +413,13 @@ async function startAgentHost(): Promise {
// publishing the metadata that advertises it, so a client can't connect
// in the gap and be missed.
localDataPlaneDisposables.add(localEndpoint.server);
- localDataPlaneDisposables.add(new ProtocolServerHandler(
+ localDataPlaneDisposables.add(instantiationService.createInstance(
+ ProtocolServerHandler,
agentService,
agentService.stateManager,
localEndpoint.server,
localProtocolHandlerConfig,
clientFileSystemProvider,
- logService,
));
try {
await publishLocalAgentHostEndpointMetadata(environmentService.userDataPath, endpointMetadata, logService);
@@ -453,18 +459,20 @@ async function startAgentHost(): Promise {
{ instantiationService, logsHome: environmentService.logsHome },
));
- const protocolHandler = disposables.add(new ProtocolServerHandler(
+ const protocolHandler = disposables.add(instantiationService.createInstance(
+ ProtocolServerHandler,
agentService,
agentService.stateManager,
wsServer,
{
+ hostLaunchKind,
+ connectionTelemetryTracker,
defaultDirectory: URI.file(os.homedir()).toString(),
completionTriggerCharacters: agentService.completionTriggerCharacters,
terminalCommandPrefix: BANG_COMMAND_PREFIX,
otlpLogEmitter,
},
clientFileSystemProvider,
- logService,
));
disposables.add(protocolHandler.onDidChangeConnectionCount(count => connectionCountEmitter.fire(count)));
@@ -535,6 +543,8 @@ async function startAgentHost(): Promise {
logService,
otlpLogEmitter,
disposables,
+ hostLaunchKind,
+ connectionTelemetryTracker,
count => connectionCountEmitter.fire(count),
).catch(err => {
logService.error('Failed to start WebSocket server', err);
@@ -622,6 +632,8 @@ async function startWebSocketServer(
logService: ILogService,
otlpLogEmitter: OtlpLogEmitter,
disposables: DisposableStore,
+ hostLaunchKind: AgentHostLaunchKind,
+ connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker,
onConnectionCountChanged: (count: number) => void,
): Promise {
const port = process.env['VSCODE_AGENT_HOST_PORT'];
@@ -653,18 +665,20 @@ async function startWebSocketServer(
{ instantiationService, logsHome },
));
- const protocolHandler = disposables.add(new ProtocolServerHandler(
+ const protocolHandler = disposables.add(instantiationService.createInstance(
+ ProtocolServerHandler,
agentService,
agentService.stateManager,
wsServer,
{
+ hostLaunchKind,
+ connectionTelemetryTracker,
defaultDirectory: URI.file(os.homedir()).toString(),
completionTriggerCharacters: agentService.completionTriggerCharacters,
terminalCommandPrefix: BANG_COMMAND_PREFIX,
otlpLogEmitter,
},
clientFileSystemProvider,
- logService,
));
disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged));
diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts
index 58a8e2a314f..b27d471026b 100644
--- a/src/vs/platform/agentHost/node/agentHostServerMain.ts
+++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts
@@ -61,6 +61,7 @@ import { IAgentHostCompletions } from './agentHostCompletions.js';
import { IAgentHostTerminalManager } from './agentHostTerminalManager.js';
import { WebSocketProtocolServer } from './webSocketTransport.js';
import { ProtocolServerHandler } from './protocolServerHandler.js';
+import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js';
import { FileService } from '../../files/common/fileService.js';
import { IFileService } from '../../files/common/files.js';
import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js';
@@ -89,6 +90,7 @@ import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './age
import { createAgentHostTelemetryService } from './agentHostTelemetryService.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js';
+import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js';
/** Log to stderr so messages appear in the terminal alongside the process. */
function log(msg: string): void {
@@ -256,7 +258,7 @@ async function main(): Promise {
diServices.set(IAgentHostGitService, gitService);
// Create the agent service (owns AgentHostStateManager + AgentSideEffects internally)
- const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]);
+ const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], AgentHostLaunchKind.VSCodeCLI);
disposables.add(agentService);
diServices.set(IAgentService, agentService);
diServices.set(IAgentHostStateManager, agentService.stateManager);
@@ -405,20 +407,23 @@ async function main(): Promise {
const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider());
disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider));
+ const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker());
// Wire up protocol handler
- disposables.add(new ProtocolServerHandler(
+ disposables.add(instantiationService.createInstance(
+ ProtocolServerHandler,
agentService,
agentService.stateManager,
wsServer,
{
+ hostLaunchKind: AgentHostLaunchKind.VSCodeCLI,
+ connectionTelemetryTracker,
defaultDirectory: URI.file(os.homedir()).toString(),
completionTriggerCharacters: agentService.completionTriggerCharacters,
terminalCommandPrefix: BANG_COMMAND_PREFIX,
otlpLogEmitter,
},
clientFileSystemProvider,
- logService,
));
// Report ready
diff --git a/src/vs/platform/agentHost/node/agentHostService.ts b/src/vs/platform/agentHost/node/agentHostService.ts
index 231d031757f..5c70ab62a87 100644
--- a/src/vs/platform/agentHost/node/agentHostService.ts
+++ b/src/vs/platform/agentHost/node/agentHostService.ts
@@ -10,12 +10,22 @@ import { RemoteLoggerChannelClient } from '../../log/common/logIpc.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { IAgentHostStarter } from '../common/agent.js';
import { reportAgentHostProcessError } from '../common/agentHostProcessTelemetry.js';
+import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js';
import { AgentHostIpcChannels } from '../common/agentService.js';
enum Constants {
MaxRestarts = 5,
}
+const WINDOWS_EXPECTED_SHUTDOWN_EXIT_CODES = new Set([
+ 0xC000026B, // STATUS_DLL_INIT_FAILED_LOGOFF
+ 0x40010004, // DBG_TERMINATE_PROCESS
+]);
+
+function isExpectedWindowsShutdownExit(platform: NodeJS.Platform, code: number): boolean {
+ return platform === 'win32' && WINDOWS_EXPECTED_SHUTDOWN_EXIT_CODES.has(code >>> 0);
+}
+
/**
* Main-process service that manages the agent host utility process lifecycle
* (lazy start, crash recovery, logger forwarding). The renderer communicates
@@ -30,6 +40,7 @@ export class AgentHostProcessManager extends Disposable {
constructor(
private readonly _starter: IAgentHostStarter,
+ private readonly _platform: NodeJS.Platform = process.platform,
@ILogService private readonly _logService: ILogService,
@ILoggerService private readonly _loggerService: ILoggerService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@@ -67,27 +78,35 @@ export class AgentHostProcessManager extends Disposable {
this._logService.info('AgentHostProcessManager: agent host started');
// Connect logger channel so agent host logs appear in the output channel
- this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger)));
+ connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger)));
// Handle unexpected exit
- this._register(connection.onDidProcessExit(e => {
- if (!this._wasQuitRequested && !this._store.isDisposed) {
- const willRestart = this._restartCount <= Constants.MaxRestarts;
- reportAgentHostProcessError(this._telemetryService, {
- kind: 'unexpectedExit',
- code: e.code,
- restartCount: this._restartCount,
- willRestart,
- });
- if (willRestart) {
- this._logService.error(`AgentHostProcessManager: agent host terminated unexpectedly with code ${e.code}`);
- this._restartCount++;
- this._started = false;
- connection.store.dispose();
- this._start();
- } else {
- this._logService.error(`AgentHostProcessManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`);
- }
+ connection.store.add(connection.onDidProcessExit(e => {
+ if (this._wasQuitRequested || this._store.isDisposed) {
+ return;
+ }
+ if (isExpectedWindowsShutdownExit(this._platform, e.code)) {
+ this._logService.info(`AgentHostProcessManager: agent host terminated during Windows shutdown with code ${e.code}`);
+ connection.store.dispose();
+ return;
+ }
+
+ const willRestart = this._restartCount < Constants.MaxRestarts;
+ reportAgentHostProcessError(this._telemetryService, {
+ hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
+ kind: 'unexpectedExit',
+ code: e.code,
+ restartCount: this._restartCount,
+ willRestart,
+ });
+ connection.store.dispose();
+ if (willRestart) {
+ this._logService.error(`AgentHostProcessManager: agent host terminated unexpectedly with code ${e.code}`);
+ this._restartCount++;
+ this._started = false;
+ this._start();
+ } else {
+ this._logService.error(`AgentHostProcessManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`);
}
}));
@@ -96,6 +115,7 @@ export class AgentHostProcessManager extends Disposable {
this._started = false;
this._logService.error('AgentHostProcessManager: failed to start agent host', error);
reportAgentHostProcessError(this._telemetryService, {
+ hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
kind: 'startFailed',
restartCount: this._restartCount,
willRestart: false,
diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts
index 31f29483394..fca984a7389 100644
--- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts
+++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts
@@ -16,6 +16,7 @@ import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSe
import type { ToolInvokedResult } from './agentHostToolCallTracker.js';
import { multiplexProperties, type IAgentHostRestrictedTelemetry, type IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js';
import type { AgentHostClientType } from '../common/agentHostClientInfo.js';
+import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
export type AgentHostUserMessageSentSource = 'direct' | 'queued';
@@ -41,7 +42,11 @@ export type IAgentHostExecutionModeChangedClassification = {
export interface IAgentHostUserMessageSentEvent {
provider: string;
+ hostLaunchKind: AgentHostLaunchKind;
+ initiatorClientId: string | undefined;
initiatorClientType: AgentHostClientType;
+ initiatorConnectionKind: AgentHostClientConnectionKind;
+ initiatorTransportKind: AgentHostTransportKind;
agentSessionId: string;
source: AgentHostUserMessageSentSource;
isSubagentSession: boolean;
@@ -54,7 +59,11 @@ export interface IAgentHostUserMessageSentEvent {
export type IAgentHostUserMessageSentClassification = {
provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' };
+ hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' };
+ initiatorClientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier that initiated the user message.' };
initiatorClientType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of AHP client that initiated the user message.' };
+ initiatorConnectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The route the initiating client declared it used to reach the agent host.' };
+ initiatorTransportKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The physical transport on which the agent host received the initiating client action.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' };
source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user message was sent directly or from the queued-message flow.' };
isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the message was sent to a subagent session.' };
@@ -67,6 +76,61 @@ export type IAgentHostUserMessageSentClassification = {
comment: 'Tracks user messages sent from the agent host process to an agent provider.';
};
+export type AgentHostClientConnectionAction = 'connected' | 'disconnected';
+
+export interface IAgentHostClientConnectionEvent {
+ action: AgentHostClientConnectionAction;
+ hostLaunchKind: AgentHostLaunchKind;
+ clientId: string;
+ clientType: AgentHostClientType;
+ clientImplementationName: string | undefined;
+ clientImplementationVersion: string | undefined;
+ connectionKind: AgentHostClientConnectionKind;
+ transportKind: AgentHostTransportKind;
+ protocolVersion: string;
+ isReconnect: boolean;
+ connectedClientCount: number;
+ connectedTransportCount: number;
+ clientTransportCount: number;
+ connectionDurationMs: number | undefined;
+ subscriptionCount: number | undefined;
+}
+
+export type IAgentHostClientConnectionClassification = {
+ action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether an initialized AHP client transport connected or disconnected.' };
+ hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' };
+ clientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier.' };
+ clientType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The bounded type of the connected AHP client.' };
+ clientImplementationName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The implementation name declared by the AHP client.' };
+ clientImplementationVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The implementation version declared by the AHP client.' };
+ connectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The route the client declared it used to reach the agent host.' };
+ transportKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The physical transport accepted by the agent host.' };
+ protocolVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The negotiated AHP protocol version.' };
+ isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this client identifier was previously known to the agent host.' };
+ connectedClientCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of logical AHP clients with at least one live transport after this lifecycle change.' };
+ connectedTransportCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of live initialized AHP transports after this lifecycle change.' };
+ clientTransportCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of live initialized transports for this client after this lifecycle change.' };
+ connectionDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The duration of the disconnected transport in milliseconds.' };
+ subscriptionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of protocol subscriptions held by the client transport when it disconnected.' };
+ owner: 'roblourens';
+ comment: 'Tracks initialized Agent Host client connection topology and lifecycle.';
+};
+
+export interface IAgentHostClientConnectionReport {
+ action: AgentHostClientConnectionAction;
+ context: IAgentHostClientTelemetryContext;
+ clientId: string;
+ clientImplementationName: string | undefined;
+ clientImplementationVersion: string | undefined;
+ protocolVersion: string;
+ isReconnect: boolean;
+ connectedClientCount: number;
+ connectedTransportCount: number;
+ clientTransportCount: number;
+ connectionDurationMs?: number;
+ subscriptionCount?: number;
+}
+
export type AgentHostTurnResult = 'success' | 'error' | 'cancelled';
export type AgentHostModelTelemetryKind = 'trusted' | 'byok' | 'unknown';
type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit';
@@ -484,13 +548,17 @@ export class AgentHostTelemetryReporter {
});
}
- userMessageSent(provider: string, clientType: AgentHostClientType, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void {
+ userMessageSent(provider: string, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void {
const attachmentCount = attachments?.length ?? 0;
const activeClients = sessionState?.activeClients ?? [];
const sessionUri = isAhpChatChannel(session) ? parseRequiredSessionUriFromChatUri(session) : session;
this._telemetryService.publicLog2('agentHost.userMessageSent', {
provider,
- initiatorClientType: clientType,
+ hostLaunchKind: clientContext.hostLaunchKind,
+ initiatorClientId: clientId,
+ initiatorClientType: clientContext.clientType,
+ initiatorConnectionKind: clientContext.connectionKind,
+ initiatorTransportKind: clientContext.transportKind,
agentSessionId: AgentSession.id(sessionUri),
source,
isSubagentSession: isSubagentSession(sessionUri),
@@ -504,6 +572,26 @@ export class AgentHostTelemetryReporter {
});
}
+ clientConnection(report: IAgentHostClientConnectionReport): void {
+ this._telemetryService.publicLog2('agentHost.clientConnection', {
+ action: report.action,
+ hostLaunchKind: report.context.hostLaunchKind,
+ clientId: report.clientId,
+ clientType: report.context.clientType,
+ clientImplementationName: report.clientImplementationName,
+ clientImplementationVersion: report.clientImplementationVersion,
+ connectionKind: report.context.connectionKind,
+ transportKind: report.context.transportKind,
+ protocolVersion: report.protocolVersion,
+ isReconnect: report.isReconnect,
+ connectedClientCount: report.connectedClientCount,
+ connectedTransportCount: report.connectedTransportCount,
+ clientTransportCount: report.clientTransportCount,
+ connectionDurationMs: report.connectionDurationMs,
+ subscriptionCount: report.subscriptionCount,
+ });
+ }
+
/**
* Mirrors the Copilot extension's enhanced GH `request.options.tools` event for the agent-host
* flow. The extension emits it per LLM request from its model fetcher; the agent host observes
diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts
index b6f494e938f..885efba5932 100644
--- a/src/vs/platform/agentHost/node/agentService.ts
+++ b/src/vs/platform/agentHost/node/agentService.ts
@@ -69,6 +69,7 @@ import { INetworkDiagnosticsService } from './networkDiagnosticsService.js';
import { parseMcpChannelUri } from './shared/mcpCustomizationController.js';
import { toAgentClientUri } from '../common/agentClientUri.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
+import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js';
import { AgentHostGitStateService } from './agentHostGitStateService.js';
import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
@@ -399,6 +400,7 @@ export class AgentService extends Disposable implements IAgentService {
copilotApiService?: ICopilotApiService,
fetchFn?: typeof globalThis.fetch,
providerConfigurations: readonly IAgentCustomizationSettingsRegistration[] = [],
+ private readonly _hostLaunchKind = AgentHostLaunchKind.Unknown,
) {
super();
this._logService.info('AgentService initialized');
@@ -530,6 +532,7 @@ export class AgentService extends Disposable implements IAgentService {
sessionDataService: this._sessionDataService,
localTurns: this._localTurns,
agents: this._agents,
+ hostLaunchKind: this._hostLaunchKind,
copilotApiService: effectiveCopilotApiService,
getGitHubCopilotToken: () => {
return this.getAuthToken({
@@ -2574,7 +2577,10 @@ export class AgentService extends Disposable implements IAgentService {
*/
private readonly _clientDispatchQueues = new Map>();
- dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientType = AgentHostClientType.Unknown): void {
+ dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void {
+ const clientContext = typeof clientContextOrType === 'string'
+ ? createUnknownAgentHostClientTelemetryContext(clientContextOrType)
+ : clientContextOrType;
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
// Clients dispatch chat (chat) actions against a chat channel
@@ -2589,7 +2595,7 @@ export class AgentService extends Disposable implements IAgentService {
const pending = this._clientDispatchQueues.get(clientId);
if (!pending && !requiresPeerResolution && !requiresAttachmentRewrite) {
- this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientType);
+ this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext);
return;
}
const next = (pending ?? Promise.resolve()).then(async () => {
@@ -2607,7 +2613,7 @@ export class AgentService extends Disposable implements IAgentService {
}
this._changesets.refreshBranchChangeset(changeset.sessionUri);
}
- this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientType);
+ this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientContext);
}).catch(err => {
this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`);
});
@@ -2649,10 +2655,10 @@ export class AgentService extends Disposable implements IAgentService {
return resolveSessionWorkingDirectoryAction(action, state.workingDirectories, capability.immutablePrimary === true);
}
- private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientType: AgentHostClientType): void {
+ private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void {
const origin = { clientId, clientSeq };
if (action.type === ActionType.SessionWorkingDirectorySet || action.type === ActionType.SessionWorkingDirectoryRemoved) {
- if (clientType !== AgentHostClientType.EditorWindow) {
+ if (clientContext.clientType !== AgentHostClientType.EditorWindow) {
this._stateManager.rejectClientAction(channel, action, origin, 'Session working-directory actions require an Editor Window client.');
return;
}
@@ -2675,7 +2681,7 @@ export class AgentService extends Disposable implements IAgentService {
this._editAttributionService?.setEnabled(editTelemetryEnabled);
}
}
- this._sideEffects.handleAction(channel, action, clientId, clientType);
+ this._sideEffects.handleAction(channel, action, clientId, clientContext);
}
private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts
index 7eb5c0729cc..0830c2b4302 100644
--- a/src/vs/platform/agentHost/node/agentSideEffects.ts
+++ b/src/vs/platform/agentHost/node/agentSideEffects.ts
@@ -19,6 +19,7 @@ import { IAgentHostChangesetService } from '../common/agentHostChangesetService.
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
import type { SessionMode } from '../common/agentHostSchema.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
+import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js';
import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js';
@@ -126,11 +127,13 @@ export interface IAgentSideEffectsOptions {
* GitHub issues the message references).
*/
readonly onUserMessage?: (session: ProtocolURI, text: string) => void;
+ /** Process launcher used when client-origin metadata is unavailable. */
+ readonly hostLaunchKind?: AgentHostLaunchKind;
}
interface IQueuedMessageSender {
readonly clientId: string | undefined;
- readonly clientType: AgentHostClientType;
+ readonly clientContext: IAgentHostClientTelemetryContext;
}
/** A signal that was deferred because its subagent session does not exist yet. */
@@ -1290,7 +1293,13 @@ export class AgentSideEffects extends Disposable {
this._stateManager.dispatchServerAction(sessionKey, readyAction);
}
- handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientType = AgentHostClientType.Unknown): void {
+ handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void {
+ let clientContext = typeof clientContextOrType === 'string'
+ ? createUnknownAgentHostClientTelemetryContext(clientContextOrType)
+ : clientContextOrType;
+ if (this._options.hostLaunchKind !== undefined) {
+ clientContext = { ...clientContext, hostLaunchKind: this._options.hostLaunchKind };
+ }
const chatChannel = isAhpChatChannel(channel) ? channel : undefined;
const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel;
switch (action.type) {
@@ -1331,7 +1340,7 @@ export class AgentSideEffects extends Disposable {
return;
}
const attachments = action.message.attachments;
- this._telemetryReporter.userMessageSent(agent.id, clientType, channel, state, 'direct', attachments);
+ this._telemetryReporter.userMessageSent(agent.id, clientId, clientContext, channel, state, 'direct', attachments);
const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, state, action.message.model?.id);
this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel);
void this._sendTurnMessage({
@@ -1342,7 +1351,7 @@ export class AgentSideEffects extends Disposable {
message: action.message,
turnId: action.turnId,
senderClientId: clientId,
- clientType,
+ clientType: clientContext.clientType,
turnStopWatch,
});
break;
@@ -1420,7 +1429,7 @@ export class AgentSideEffects extends Disposable {
}
const queuedMessageExists = this._stateManager.getChatState(channel)?.queuedMessages?.some(message => message.id === action.id) === true;
if (action.kind === PendingMessageKind.Queued && queuedMessageExists) {
- this._queuedMessageSenders.set({ clientId, clientType }, channel, action.id);
+ this._queuedMessageSenders.set({ clientId, clientContext }, channel, action.id);
}
this._syncPendingMessages(channel);
break;
@@ -1703,7 +1712,13 @@ export class AgentSideEffects extends Disposable {
}
const msg = state.queuedMessages[0];
- const sender = this._queuedMessageSenders.get(session, msg.id) ?? { clientId: undefined, clientType: AgentHostClientType.Unknown };
+ const sender = this._queuedMessageSenders.get(session, msg.id) ?? {
+ clientId: undefined,
+ clientContext: {
+ ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown),
+ hostLaunchKind: this._options.hostLaunchKind ?? AgentHostLaunchKind.Unknown,
+ },
+ };
this._queuedMessageSenders.delete(session, msg.id);
const turnId = generateUuid();
@@ -1751,7 +1766,7 @@ export class AgentSideEffects extends Disposable {
}
const attachments = msg.message.attachments;
const queuedState = this._stateManager.getSessionState(session);
- this._telemetryReporter.userMessageSent(agent.id, sender.clientType, session, queuedState, 'queued', attachments);
+ this._telemetryReporter.userMessageSent(agent.id, sender.clientId, sender.clientContext, session, queuedState, 'queued', attachments);
const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id);
this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel);
// Selection travels on the queued message; it is applied before sending.
@@ -1763,7 +1778,7 @@ export class AgentSideEffects extends Disposable {
message: msg.message,
turnId,
senderClientId: sender.clientId,
- clientType: sender.clientType,
+ clientType: sender.clientContext.clientType,
turnStopWatch,
});
}
diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts
index 79b13ea787d..10c57e9f1b2 100644
--- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts
+++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts
@@ -36,6 +36,7 @@ import { isSubagentSession, parseSubagentSessionUri, buildDefaultChatUri, parseC
import { IAgentConfigurationService } from '../agentConfigurationService.js';
import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
import { IAgentHostGitService } from '../../common/agentHostGitService.js';
+import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js';
import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
import { projectFromCopilotContext } from '../copilot/copilotGitProject.js';
import { ICopilotApiService } from '../shared/copilotApiService.js';
@@ -452,6 +453,7 @@ export class ClaudeAgent extends Disposable implements IAgent {
@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
@IAgentHostOTelService private readonly _otelService: IAgentHostOTelService,
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
+ @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
@IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@@ -1180,10 +1182,17 @@ export class ClaudeAgent extends Disposable implements IAgent {
// Emit the full resolved set (index 0 = process root, 1..N = additional
// roots). Falls back to the session's own ordered set when the host
// didn't hand us one (e.g. workspace-less single-root).
+ const materializedWorkingDirectories = workingDirectories ?? session.workingDirectories;
+
+ // Pass the resolved directories before the materialize event updates them in the state manager.
+ this._checkpointService.captureBaselineCheckpoint(session.sessionUri, materializedWorkingDirectories).catch(err => {
+ this._logService.warn(`[Claude:${sessionId}] Baseline checkpoint capture failed: ${err instanceof Error ? err.message : String(err)}`);
+ });
+
this._onDidMaterializeSession.fire({
session: session.sessionUri,
project: session.project,
- workingDirectories: workingDirectories ?? session.workingDirectories,
+ workingDirectories: materializedWorkingDirectories,
});
return session;
diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts
index 34727304fe3..e6fb1a67cd1 100644
--- a/src/vs/platform/agentHost/node/codex/codexAgent.ts
+++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts
@@ -49,6 +49,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme
import { IAgentPluginManager, type ISyncedCustomization } from '../../common/agentPluginManager.js';
import { parsePlugin } from '../../../agentPlugins/common/pluginParsers.js';
import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
+import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js';
import { ICopilotApiService } from '../shared/copilotApiService.js';
import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js';
import { IAgentSdkDownloader, IAgentSdkPackage } from '../agentSdkDownloader.js';
@@ -867,6 +868,7 @@ export class CodexAgent extends Disposable implements IAgent {
@ICodexProxyService private readonly _codexProxyService: ICodexProxyService,
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
@IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
+ @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
@IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader,
@IProductService private readonly _productService: IProductService,
@IAgentPluginManager private readonly _pluginManager: IAgentPluginManager,
@@ -3543,6 +3545,15 @@ export class CodexAgent extends Disposable implements IAgent {
this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration });
return;
}
+
+ // Check needsResume before the resume block clears it so restored sessions never receive a late baseline.
+ if (!session.firstTurnSent && !session.needsResume) {
+ const baselineWorkingDirectories = session.workingDirectories ?? (session.workingDirectory ? [session.workingDirectory] : undefined);
+ this._checkpointService.captureBaselineCheckpoint(sessionUri, baselineWorkingDirectories).catch(err => {
+ this._logService.warn(`[Codex:${sessionId}] Baseline checkpoint capture failed: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ }
+
// Codex registers client tools and MCP servers only at `thread/start`.
// If the thread was prewarmed (or otherwise started) before the current
// client tools / MCP servers were known, restart it now — before any
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
index 44642b137ac..8bf5469889e 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
@@ -1640,27 +1640,32 @@ export class CopilotAgentSession extends Disposable {
}
private _toToolSearchResult(clientResult: ToolResultObject, availableTools: readonly CurrentToolMetadata[] | undefined): ToolResultObject {
- const deferred = new Set();
+ const deferred = new Map();
for (const tool of availableTools ?? []) {
if (tool.deferLoading) {
- deferred.add(tool.name);
+ deferred.set(tool.name, tool.name);
if (tool.namespacedName) {
- deferred.add(tool.namespacedName);
+ deferred.set(tool.namespacedName, tool.name);
}
}
}
- const clientNames = this._parseToolSearchNames(clientResult.textResultForLlm);
- const toolReferences = clientNames.filter(name => deferred.has(name));
+ const parsedClientNames = this._parseToolSearchNames(clientResult.textResultForLlm);
+ const clientNames = parsedClientNames ?? [];
+ const toolReferences = [...new Set(clientNames.map(name => deferred.get(name)).filter(isDefined))];
this._logService.info(`[Copilot:${this.sessionId}] tool_search override: availableTools=${availableTools?.length ?? 0}, deferred=${deferred.size}, clientMatched=[${clientNames.join(', ')}] -> toolReferences=[${toolReferences.join(', ')}]`);
- return { ...clientResult, toolReferences };
+ return {
+ ...clientResult,
+ ...(clientResult.resultType === 'success' && parsedClientNames !== undefined ? { textResultForLlm: JSON.stringify(toolReferences) } : {}),
+ toolReferences,
+ };
}
- private _parseToolSearchNames(text: string): string[] {
+ private _parseToolSearchNames(text: string): string[] | undefined {
try {
const parsed = JSON.parse(text);
- return Array.isArray(parsed) ? parsed.filter((name): name is string => typeof name === 'string') : [];
+ return Array.isArray(parsed) ? parsed.filter((name): name is string => typeof name === 'string') : undefined;
} catch {
- return [];
+ return undefined;
}
}
diff --git a/src/vs/platform/agentHost/node/messagePortProtocolServer.ts b/src/vs/platform/agentHost/node/messagePortProtocolServer.ts
index 72786f08523..5e73488e110 100644
--- a/src/vs/platform/agentHost/node/messagePortProtocolServer.ts
+++ b/src/vs/platform/agentHost/node/messagePortProtocolServer.ts
@@ -6,6 +6,7 @@
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js';
+import { AgentHostTransportKind } from '../common/agentHostTelemetry.js';
import { JSON_RPC_PARSE_ERROR, type AhpServerNotification, type JsonRpcNotification, type JsonRpcParseErrorResponse, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
@@ -110,6 +111,7 @@ export class MessagePortProtocolServer extends Disposable implements I
}
class MessagePortProtocolTransport extends Disposable implements IProtocolTransport {
+ readonly transportKind = AgentHostTransportKind.MessagePort;
private readonly _onFrame = this._register(new Emitter());
readonly onFrame = this._onFrame.event;
diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts
index 7457fa9187f..af679bca5c7 100644
--- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts
+++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts
@@ -14,6 +14,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi
import { ILogService } from '../../log/common/log.js';
import { getResolvedShellEnv } from '../../shell/node/shellEnv.js';
import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js';
+import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar } from '../common/agentHostTelemetry.js';
import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js';
import '../common/agentHostStarter.config.contribution.js';
@@ -77,6 +78,7 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte
VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain',
VSCODE_PIPE_LOGGING: 'true',
VSCODE_VERBOSE_LOGGING: 'true',
+ [AgentHostLaunchKindEnvVar]: AgentHostLaunchKind.VSCodeCLI,
};
// Forward the Claude/Codex SDK overrides + codex home/args from
diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts
index 56be10d7864..553161032f0 100644
--- a/src/vs/platform/agentHost/node/protocolServerHandler.ts
+++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts
@@ -7,11 +7,14 @@ import { disposableTimeout } from '../../../base/common/async.js';
import { Emitter } from '../../../base/common/event.js';
import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js';
import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js';
+import { StopWatch } from '../../../base/common/stopwatch.js';
import { hasKey } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
+import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js';
import { getAgentHostClientType } from '../common/agentHostClientInfo.js';
+import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { AgentSession, type IAgentCreateChatOptions, type IAgentService, type IMcpNotification } from '../common/agentService.js';
import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js';
import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js';
@@ -57,6 +60,8 @@ import {
} from '../common/otlp/otlpLogEmitter.js';
import { isFileResourceRead } from '../common/resourceReadLogging.js';
import type { Implementation } from '../common/state/protocol/common/commands.js';
+import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js';
+import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js';
/** Default capacity of the server-side action replay buffer. */
const REPLAY_BUFFER_CAPACITY = 1000;
@@ -192,8 +197,13 @@ type ChannelSubscription =
interface IConnectedClient {
readonly clientId: string;
readonly clientInfo: Implementation | undefined;
+ readonly telemetryContext: IAgentHostClientTelemetryContext;
readonly protocolVersion: string;
readonly transport: IProtocolTransport;
+ readonly connectionStopWatch: StopWatch;
+ readonly telemetryTransportToken: object;
+ readonly isReconnect: boolean;
+ telemetryConnectionActive: boolean;
/**
* Every channel the client is currently subscribed to, keyed by the
* canonical channel URI. OTLP channel URIs are canonicalised to
@@ -202,6 +212,7 @@ interface IConnectedClient {
*/
readonly subscriptions: Map;
readonly disposables: DisposableStore;
+ readonly initializationDisposables: DisposableStore;
}
/**
@@ -240,6 +251,8 @@ interface IActiveClientRecord {
interface IGraceClientRecord {
readonly state: 'grace';
readonly clientInfo: Implementation | undefined;
+ readonly telemetryContext: IAgentHostClientTelemetryContext | undefined;
+ readonly protocolVersion: string | undefined;
/**
* Epoch ms when the client last had a live transport, or when this record
* was created for a never-connected orphan tool-call stamp. Pins the grace
@@ -288,6 +301,11 @@ function classifyChannel(channel: string): ChannelSubscription | undefined {
* Configuration for protocol-level concerns outside of IAgentService.
*/
export interface IProtocolServerConfig {
+ /** Process launcher that owns this agent host. */
+ readonly hostLaunchKind?: AgentHostLaunchKind;
+ /** Process-wide client count tracker shared by every listener in this host. */
+ readonly connectionTelemetryTracker?: AgentHostClientConnectionTelemetryTracker;
+
/** Default directory returned to clients during the initialize handshake. */
readonly defaultDirectory?: string;
/**
@@ -333,6 +351,8 @@ export class ProtocolServerHandler extends Disposable {
*/
private readonly _clients = new Map();
private readonly _replayBuffer: ActionEnvelope[] = [];
+ private readonly _telemetryReporter: AgentHostTelemetryReporter;
+ private readonly _connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker;
private readonly _onDidChangeConnectionCount = this._register(new Emitter());
@@ -346,8 +366,11 @@ export class ProtocolServerHandler extends Disposable {
private readonly _config: IProtocolServerConfig,
private readonly _clientFileSystemProvider: AHPFileSystemProvider,
@ILogService private readonly _logService: ILogService,
+ @ITelemetryService telemetryService: ITelemetryService,
) {
super();
+ this._telemetryReporter = new AgentHostTelemetryReporter(telemetryService);
+ this._connectionTelemetryTracker = this._config.connectionTelemetryTracker ?? this._register(new AgentHostClientConnectionTelemetryTracker());
this._register(this._server.onConnection(transport => {
this._handleNewConnection(transport);
@@ -472,7 +495,7 @@ export class ProtocolServerHandler extends Disposable {
`Unsupported action: ${action.type}`,
);
} else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || action.type === ActionType.RootConfigChanged) {
- this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, getAgentHostClientType(client.clientInfo));
+ this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, client.telemetryContext);
}
}
break;
@@ -505,10 +528,18 @@ export class ProtocolServerHandler extends Disposable {
this._rejectPendingReverseRequestsForConnection(client);
if (record.connections.length === 0) {
this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${subscriptionCount}`);
- this._clients.set(client.clientId, { state: 'grace', clientInfo: record.clientInfo, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap() });
+ this._clients.set(client.clientId, {
+ state: 'grace',
+ clientInfo: record.clientInfo,
+ telemetryContext: client.telemetryContext,
+ protocolVersion: client.protocolVersion,
+ lastSeenAt: Date.now(),
+ disconnectTimeouts: new DisposableMap(),
+ });
this._handleClientDisconnected(client.clientId);
this._onDidChangeConnectionCount.fire(this._connectedClientCount);
}
+ this._reportClientDisconnected(client, subscriptionCount);
}
}
disposables.dispose();
@@ -547,41 +578,70 @@ export class ProtocolServerHandler extends Disposable {
);
}
+ const previousRecord = this._clients.get(params.clientId);
+ const telemetryTransportToken = {};
+ const initializationDisposables = disposables.add(new DisposableStore());
+ const telemetryContext = this._createClientTelemetryContext(params.clientInfo, params._meta, transport);
const client: IConnectedClient = {
clientId: params.clientId,
clientInfo: params.clientInfo,
+ telemetryContext,
protocolVersion: negotiated,
transport,
+ connectionStopWatch: StopWatch.create(true),
+ telemetryTransportToken,
+ isReconnect: this._connectionTelemetryTracker.hasSeenClient(params.clientId),
+ telemetryConnectionActive: false,
subscriptions: new Map(),
disposables,
+ initializationDisposables,
};
this._attachConnection(params.clientId, client);
+ try {
+ this._registerClientFileSystemAuthority(params.clientId, initializationDisposables);
- this._registerClientFileSystemAuthority(params.clientId, disposables);
-
-
- const snapshots: IStateSnapshot[] = [];
- if (params.initialSubscriptions) {
- for (const uri of params.initialSubscriptions) {
- const snapshot = this._addInitialSubscription(client, uri.toString());
- if (snapshot) {
- snapshots.push(snapshot);
+ const snapshots: IStateSnapshot[] = [];
+ if (params.initialSubscriptions) {
+ for (const uri of params.initialSubscriptions) {
+ const snapshot = this._addInitialSubscription(client, uri.toString());
+ if (snapshot) {
+ snapshots.push(snapshot);
+ }
}
}
- }
- return {
- client,
- response: {
- protocolVersion: negotiated,
- serverSeq: this._stateManager.serverSeq,
- snapshots,
- defaultDirectory: this._config.defaultDirectory,
- completionTriggerCharacters: this._config.completionTriggerCharacters,
- terminalCommandPrefix: this._config.terminalCommandPrefix,
- telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined,
- },
- };
+ const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken);
+ client.telemetryConnectionActive = true;
+ if (previousRecord?.state === 'grace') {
+ previousRecord.disconnectTimeouts.dispose();
+ }
+ this._onDidChangeConnectionCount.fire(this._connectedClientCount);
+ this._telemetryReporter.clientConnection({
+ action: 'connected',
+ context: telemetryContext,
+ clientId: client.clientId,
+ clientImplementationName: client.clientInfo?.name,
+ clientImplementationVersion: client.clientInfo?.version,
+ protocolVersion: client.protocolVersion,
+ ...counts,
+ });
+
+ return {
+ client,
+ response: {
+ protocolVersion: negotiated,
+ serverSeq: this._stateManager.serverSeq,
+ snapshots,
+ defaultDirectory: this._config.defaultDirectory,
+ completionTriggerCharacters: this._config.completionTriggerCharacters,
+ terminalCommandPrefix: this._config.terminalCommandPrefix,
+ telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined,
+ },
+ };
+ } catch (error) {
+ this._rollbackFailedInitialization(client, previousRecord);
+ throw error;
+ }
}
/**
@@ -666,28 +726,62 @@ export class ProtocolServerHandler extends Disposable {
// Synchronously install the client so messages arriving on this transport
// while we restore subscriptions can find a valid client object. The
// reconnect response is only sent once `responsePromise` resolves below.
+ const priorTelemetryContext = existingRecord.state === 'active'
+ ? existingRecord.connections.at(-1)?.telemetryContext
+ : existingRecord.telemetryContext;
+ const priorProtocolVersion = existingRecord.state === 'active'
+ ? existingRecord.connections.at(-1)?.protocolVersion
+ : existingRecord.protocolVersion;
+ const telemetryTransportToken = {};
+ const initializationDisposables = disposables.add(new DisposableStore());
const client: IConnectedClient = {
clientId: params.clientId,
clientInfo: existingRecord.clientInfo,
- protocolVersion: PROTOCOL_VERSION,
+ telemetryContext: this._createClientTelemetryContext(existingRecord.clientInfo, params._meta, transport, priorTelemetryContext?.connectionKind),
+ protocolVersion: priorProtocolVersion ?? PROTOCOL_VERSION,
transport,
+ connectionStopWatch: StopWatch.create(true),
+ telemetryTransportToken,
+ isReconnect: true,
+ telemetryConnectionActive: false,
subscriptions: new Map(),
disposables,
+ initializationDisposables,
};
this._attachConnection(params.clientId, client);
+ try {
+ // Re-establish the reverse-RPC filesystem authority for this client.
+ // The prior transport's `onClose` disposed the previous registration,
+ // so without this step any subsequent `resourceRead` / `resourceWrite`
+ // / etc. from the agent host would fail with "no connection registered
+ // for authority" until the client disconnected and re-initialized.
+ this._registerClientFileSystemAuthority(params.clientId, initializationDisposables);
- // Re-establish the reverse-RPC filesystem authority for this client.
- // The prior transport's `onClose` disposed the previous registration,
- // so without this step any subsequent `resourceRead` / `resourceWrite`
- // / etc. from the agent host would fail with "no connection registered
- // for authority" until the client disconnected and re-initialized.
- this._registerClientFileSystemAuthority(params.clientId, disposables);
+ const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq;
+ const canReplay = params.lastSeenServerSeq >= oldestBuffered;
+ const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay);
- const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq;
- const canReplay = params.lastSeenServerSeq >= oldestBuffered;
+ const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken);
+ client.telemetryConnectionActive = true;
+ if (existingRecord.state === 'grace') {
+ existingRecord.disconnectTimeouts.dispose();
+ }
+ this._onDidChangeConnectionCount.fire(this._connectedClientCount);
+ this._telemetryReporter.clientConnection({
+ action: 'connected',
+ context: client.telemetryContext,
+ clientId: client.clientId,
+ clientImplementationName: client.clientInfo?.name,
+ clientImplementationVersion: client.clientInfo?.version,
+ protocolVersion: client.protocolVersion,
+ ...counts,
+ });
- const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay);
- return { client, responsePromise };
+ return { client, responsePromise };
+ } catch (error) {
+ this._rollbackFailedInitialization(client, existingRecord);
+ throw error;
+ }
}
/**
@@ -966,11 +1060,29 @@ export class ProtocolServerHandler extends Disposable {
existing.connections.push(client);
existing.clientInfo = client.clientInfo ?? existing.clientInfo;
} else {
- existing?.disconnectTimeouts.dispose();
this._clients.set(clientId, { state: 'active', clientInfo: client.clientInfo ?? existing?.clientInfo, connections: [client] });
}
this._pruneClientRecords();
- this._onDidChangeConnectionCount.fire(this._connectedClientCount);
+ }
+
+ private _rollbackFailedInitialization(client: IConnectedClient, previousRecord: IClientRecord | undefined): void {
+ const record = this._clients.get(client.clientId);
+ if (record?.state === 'active') {
+ const connectionIndex = record.connections.indexOf(client);
+ if (connectionIndex !== -1) {
+ record.connections.splice(connectionIndex, 1);
+ this._releaseClientSubscriptions(client, record);
+ this._rejectPendingReverseRequestsForConnection(client);
+ }
+ if (record.connections.length === 0) {
+ if (previousRecord?.state === 'grace') {
+ this._clients.set(client.clientId, previousRecord);
+ } else {
+ this._clients.delete(client.clientId);
+ }
+ }
+ }
+ client.initializationDisposables.dispose();
}
/**
@@ -987,7 +1099,14 @@ export class ProtocolServerHandler extends Disposable {
if (record) {
return record;
}
- const created: IGraceClientRecord = { state: 'grace', clientInfo: undefined, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap() };
+ const created: IGraceClientRecord = {
+ state: 'grace',
+ clientInfo: undefined,
+ telemetryContext: undefined,
+ protocolVersion: undefined,
+ lastSeenAt: Date.now(),
+ disconnectTimeouts: new DisposableMap(),
+ };
this._clients.set(clientId, created);
return created;
}
@@ -1040,6 +1159,36 @@ export class ProtocolServerHandler extends Disposable {
return count;
}
+ private _createClientTelemetryContext(clientInfo: Implementation | undefined, meta: Record | undefined, transport: IProtocolTransport, fallbackConnectionKind = AgentHostClientConnectionKind.Unknown): IAgentHostClientTelemetryContext {
+ const connectionKind = readClientConnectionKind(meta);
+ return {
+ clientType: getAgentHostClientType(clientInfo),
+ connectionKind: connectionKind === AgentHostClientConnectionKind.Unknown ? fallbackConnectionKind : connectionKind,
+ transportKind: transport.transportKind ?? AgentHostTransportKind.Unknown,
+ hostLaunchKind: this._config.hostLaunchKind ?? AgentHostLaunchKind.Unknown,
+ };
+ }
+
+ private _reportClientDisconnected(client: IConnectedClient, subscriptionCount: number): void {
+ if (!client.telemetryConnectionActive) {
+ return;
+ }
+ client.telemetryConnectionActive = false;
+ const counts = this._connectionTelemetryTracker.disconnect(client.clientId, client.telemetryTransportToken);
+ this._telemetryReporter.clientConnection({
+ action: 'disconnected',
+ context: client.telemetryContext,
+ clientId: client.clientId,
+ clientImplementationName: client.clientInfo?.name,
+ clientImplementationVersion: client.clientInfo?.version,
+ protocolVersion: client.protocolVersion,
+ isReconnect: client.isReconnect,
+ ...counts,
+ connectionDurationMs: client.connectionStopWatch.elapsed(),
+ subscriptionCount,
+ });
+ }
+
/**
* Drop grace records whose timers have all fired and whose last-seen time is
* stale beyond the retention window (10× the disconnect timeout). This
@@ -1050,7 +1199,7 @@ export class ProtocolServerHandler extends Disposable {
* closes.
*/
private _pruneClientRecords(): void {
- const cutoff = Date.now() - CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT * 10;
+ const cutoff = Date.now() - AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION;
for (const [clientId, record] of this._clients) {
if (record.state === 'grace'
&& record.disconnectTimeouts.size === 0
@@ -1609,6 +1758,14 @@ export class ProtocolServerHandler extends Disposable {
for (const record of this._clients.values()) {
if (record.state === 'active') {
for (const connection of [...record.connections]) {
+ const subscriptionCount = connection.subscriptions.size;
+ const connectionIndex = record.connections.indexOf(connection);
+ if (connectionIndex !== -1) {
+ record.connections.splice(connectionIndex, 1);
+ }
+ this._releaseClientSubscriptions(connection, record);
+ this._rejectPendingReverseRequestsForConnection(connection);
+ this._reportClientDisconnected(connection, subscriptionCount);
connection.disposables.dispose();
}
} else {
diff --git a/src/vs/platform/agentHost/node/sshKnownHosts.ts b/src/vs/platform/agentHost/node/sshKnownHosts.ts
new file mode 100644
index 00000000000..c030a4825c2
--- /dev/null
+++ b/src/vs/platform/agentHost/node/sshKnownHosts.ts
@@ -0,0 +1,274 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { createHash, createHmac, timingSafeEqual } from 'crypto';
+
+/**
+ * Result of matching a presented host key against the entries in the user's
+ * `known_hosts` files.
+ *
+ * `mismatch` is deliberately scoped to entries of the *same* key type: a host
+ * that has an `ssh-rsa` entry on file but presents an `ssh-ed25519` key is
+ * `unknown` (we simply have never seen that key type for it), not evidence of
+ * an attack. Treating that as a mismatch would fire a false alarm for every
+ * user with an RSA-only entry, since ssh2 negotiates ed25519 first.
+ */
+export type KnownHostsMatch =
+ /** An entry for this host and key type matches the presented key exactly. */
+ | 'match'
+ /** An entry for this host and key type exists but holds a *different* key. */
+ | 'mismatch'
+ /** The presented key is explicitly marked `@revoked`. */
+ | 'revoked'
+ /**
+ * The only entries for this host are `@cert-authority` lines. ssh2 cannot
+ * validate host certificates (it advertises no `*-cert-v01@openssh.com`
+ * host key algorithms), so we can neither trust nor reject on this basis.
+ * Surfaced distinctly so the UI can say so plainly rather than showing an
+ * ordinary trust-on-first-use prompt for a host that deliberately set up a
+ * CA precisely to avoid one.
+ */
+ | 'ca-only'
+ /** No entry for this host and key type. */
+ | 'unknown';
+
+/**
+ * A single parsed `known_hosts` entry.
+ */
+export interface IKnownHostsEntry {
+ /** `@revoked` / `@cert-authority` marker, when present. */
+ readonly marker?: 'revoked' | 'cert-authority';
+ /**
+ * Comma-separated host patterns, already split. Empty when {@link hashedHost}
+ * is set, since hashed entries encode exactly one host per line.
+ */
+ readonly patterns: readonly string[];
+ /** Salt and hash for a `|1||` hashed entry. */
+ readonly hashedHost?: { readonly salt: Buffer; readonly hash: Buffer };
+ /** Key algorithm name, e.g. `ssh-ed25519`. */
+ readonly keyType: string;
+ /** The raw key blob (base64-decoded). */
+ readonly key: Buffer;
+}
+
+/**
+ * Compute the OpenSSH-style `SHA256:` fingerprint of a raw SSH wire-format
+ * public key blob. Matches `ssh-keygen -lf` byte for byte, including the
+ * stripped base64 padding, so the value can be compared by eye (or by copy
+ * and paste) against what the `ssh` command line displays.
+ */
+export function computeHostKeyFingerprint(keyBlob: Buffer): string {
+ const digest = createHash('sha256').update(keyBlob).digest('base64');
+ return `SHA256:${digest.replace(/=+$/, '')}`;
+}
+
+/**
+ * Read the algorithm name from the head of an SSH wire-format key blob. Every
+ * such blob begins with a length-prefixed algorithm string, so this identifies
+ * the key type without needing to parse the key material itself.
+ *
+ * Returns `undefined` when the buffer is too short or the embedded length is
+ * not self-consistent, so a malformed blob is rejected rather than producing a
+ * garbage type that could be matched against.
+ */
+export function readHostKeyType(keyBlob: Buffer): string | undefined {
+ if (keyBlob.length < 4) {
+ return undefined;
+ }
+ const length = keyBlob.readUInt32BE(0);
+ if (length === 0 || length > 64 || 4 + length > keyBlob.length) {
+ return undefined;
+ }
+ return keyBlob.subarray(4, 4 + length).toString('ascii');
+}
+
+/**
+ * Parse a single line from a `known_hosts` file. Returns `undefined` for blank
+ * lines, comments, and anything malformed — a corrupt line should be skipped
+ * rather than aborting the whole file, matching OpenSSH's own tolerance.
+ */
+export function parseKnownHostsLine(line: string): IKnownHostsEntry | undefined {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) {
+ return undefined;
+ }
+
+ const fields = trimmed.split(/\s+/);
+ let index = 0;
+
+ let marker: 'revoked' | 'cert-authority' | undefined;
+ if (fields[index]?.startsWith('@')) {
+ const raw = fields[index].substring(1);
+ if (raw !== 'revoked' && raw !== 'cert-authority') {
+ // An unrecognized marker means we cannot reason about this line at
+ // all, so skip it rather than silently treating it as unmarked.
+ return undefined;
+ }
+ marker = raw;
+ index++;
+ }
+
+ const hostField = fields[index++];
+ const keyType = fields[index++];
+ const keyBase64 = fields[index++];
+ if (!hostField || !keyType || !keyBase64) {
+ return undefined;
+ }
+
+ let key: Buffer;
+ try {
+ key = Buffer.from(keyBase64, 'base64');
+ } catch {
+ return undefined;
+ }
+ // Guard against base64 that decodes to nothing, and against a blob whose
+ // embedded algorithm name disagrees with the line's key type field.
+ if (key.length === 0 || readHostKeyType(key) !== keyType) {
+ return undefined;
+ }
+
+ if (hostField.startsWith('|1|')) {
+ const parts = hostField.split('|');
+ // Shape is ['', '1', '', ''].
+ if (parts.length !== 4) {
+ return undefined;
+ }
+ const salt = Buffer.from(parts[2], 'base64');
+ const hash = Buffer.from(parts[3], 'base64');
+ // HMAC-SHA1 digests are always 20 bytes; anything else is corrupt.
+ if (salt.length === 0 || hash.length !== 20) {
+ return undefined;
+ }
+ return { marker, patterns: [], hashedHost: { salt, hash }, keyType, key };
+ }
+
+ return { marker, patterns: hostField.split(','), keyType, key };
+}
+
+/** Parse the full contents of a `known_hosts` file, skipping malformed lines. */
+export function parseKnownHosts(contents: string): IKnownHostsEntry[] {
+ const entries: IKnownHostsEntry[] = [];
+ for (const line of contents.split('\n')) {
+ const entry = parseKnownHostsLine(line);
+ if (entry) {
+ entries.push(entry);
+ }
+ }
+ return entries;
+}
+
+/**
+ * Build the host identifiers OpenSSH would look for. A host on the default
+ * port is stored bare (`example.com`); any other port uses the bracketed form
+ * (`[example.com]:2222`).
+ */
+function hostCandidates(host: string, port: number): string[] {
+ const lower = host.toLowerCase();
+ return port === 22 ? [lower] : [`[${lower}]:${port}`];
+}
+
+/**
+ * Match a host pattern from a `known_hosts` line. Patterns support `*` (any
+ * run of characters) and `?` (a single character); everything else is literal.
+ */
+function matchesPattern(pattern: string, candidate: string): boolean {
+ const escaped = pattern.toLowerCase().replace(/[.+^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
+ return regex.test(candidate);
+}
+
+/**
+ * Whether a non-hashed entry applies to `candidate`. A leading `!` negates a
+ * pattern, and a single negation vetoes the whole entry even if another
+ * pattern on the same line matches — this mirrors OpenSSH, and getting it
+ * backwards would let an explicitly excluded host be silently trusted.
+ */
+function entryAppliesToCandidate(patterns: readonly string[], candidate: string): boolean {
+ let matched = false;
+ for (const pattern of patterns) {
+ if (pattern.startsWith('!')) {
+ if (matchesPattern(pattern.substring(1), candidate)) {
+ return false;
+ }
+ } else if (matchesPattern(pattern, candidate)) {
+ matched = true;
+ }
+ }
+ return matched;
+}
+
+/**
+ * Whether a hashed entry (`|1||`) applies to `candidate`. OpenSSH
+ * hashes the host with HMAC-SHA1 keyed by the per-entry salt.
+ */
+function hashedEntryAppliesToCandidate(hashedHost: { salt: Buffer; hash: Buffer }, candidate: string): boolean {
+ const computed = createHmac('sha1', hashedHost.salt).update(candidate).digest();
+ return computed.length === hashedHost.hash.length && timingSafeEqual(computed, hashedHost.hash);
+}
+
+/** Whether an entry applies to any of the candidate host identifiers. */
+function entryApplies(entry: IKnownHostsEntry, candidates: readonly string[]): boolean {
+ return candidates.some(candidate => entry.hashedHost
+ ? hashedEntryAppliesToCandidate(entry.hashedHost, candidate)
+ : entryAppliesToCandidate(entry.patterns, candidate));
+}
+
+/**
+ * Decide what the user's `known_hosts` entries say about a presented host key.
+ *
+ * Precedence is deliberate and mirrors OpenSSH:
+ * 1. `@revoked` wins outright — an explicitly revoked key must never be
+ * trusted, even if an ordinary entry elsewhere also matches it.
+ * 2. An exact match on host + key type + key bytes is a `match`.
+ * 3. An entry for the same host and key type holding different bytes is a
+ * `mismatch` (the classic host-key-changed warning).
+ * 4. Otherwise, if the only applicable entries are `@cert-authority` lines,
+ * report `ca-only` so the caller can explain why it cannot verify.
+ */
+export function matchKnownHosts(
+ entries: readonly IKnownHostsEntry[],
+ host: string,
+ port: number,
+ keyType: string,
+ keyBlob: Buffer,
+): KnownHostsMatch {
+ const candidates = hostCandidates(host, port);
+ const applicable = entries.filter(entry => entryApplies(entry, candidates));
+
+ // Revocation is resolved in its own pass, before anything can return a
+ // positive result. Folding it into the main loop would make the outcome
+ // depend on line order — a revoked key listed after a stale trusted entry
+ // for the same host would be accepted.
+ if (applicable.some(entry => entry.marker === 'revoked' && entry.key.equals(keyBlob))) {
+ return 'revoked';
+ }
+
+ let sawSameTypeEntry = false;
+ let sawCertAuthority = false;
+
+ for (const entry of applicable) {
+ if (entry.marker === 'revoked') {
+ continue;
+ }
+
+ if (entry.marker === 'cert-authority') {
+ sawCertAuthority = true;
+ continue;
+ }
+
+ if (entry.keyType !== keyType) {
+ continue;
+ }
+ if (entry.key.equals(keyBlob)) {
+ return 'match';
+ }
+ sawSameTypeEntry = true;
+ }
+
+ if (sawSameTypeEntry) {
+ return 'mismatch';
+ }
+ return sawCertAuthority ? 'ca-only' : 'unknown';
+}
diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts
index f6549a7368c..e4d814c25ec 100644
--- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts
+++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts
@@ -388,9 +388,24 @@ export function parseAgentEndpointsOutput(stdout: string): IAgentEndpointsResult
if (!trimmed) {
return undefined;
}
+ const candidates = [trimmed];
+ const lastLine = trimmed.split('\n').at(-1)?.trim();
+ if (lastLine && lastLine !== trimmed) {
+ candidates.push(lastLine);
+ }
+ for (const candidate of candidates) {
+ const result = parseAgentEndpointsDocument(candidate);
+ if (result) {
+ return result;
+ }
+ }
+ return undefined;
+}
+
+function parseAgentEndpointsDocument(value: string): IAgentEndpointsResult | undefined {
let raw: unknown;
try {
- raw = JSON.parse(trimmed);
+ raw = JSON.parse(value);
} catch {
return undefined;
}
@@ -418,7 +433,7 @@ export async function runAgentEndpoints(exec: ISshExec, cliBin: string, cliDataD
}
const result = parseAgentEndpointsOutput(stdout);
if (!result) {
- throw new Error(`'agent endpoints' produced unparsable output: ${JSON.stringify(stdout.slice(0, 500))}`);
+ throw new Error(`'agent endpoints' produced unparsable output (${stdout.length} characters)`);
}
return result;
}
diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
index 249ed9667de..6b85586cb3e 100644
--- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
+++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
@@ -28,11 +28,22 @@ import {
type ISSHEndpointCandidate,
type ISSHEndpointSelection,
type ISSHEndpointSelectionRequest,
+ type ISSHHostKeyVerificationRequest,
+ type ISSHHostKeysAnnouncement,
type ISSHKeyboardInteractivePrompt,
type ISSHKeyboardInteractiveRequest,
type ISSHResolvedConfig,
type SSHAgentHostLifecycle,
+ type SSHStrictHostKeyChecking,
+ SSHHostKeyDeniedError,
} from '../common/sshRemoteAgentHost.js';
+import {
+ computeHostKeyFingerprint,
+ matchKnownHosts,
+ parseKnownHosts,
+ readHostKeyType,
+ type IKnownHostsEntry,
+} from './sshKnownHosts.js';
import type { RemoteAgentHostLocationPreference } from '../common/remoteAgentHostLocationPreference.js';
import type { IRelayMessage } from '../common/relayTransport.js';
import {
@@ -78,6 +89,12 @@ interface SSHClient {
on(event: 'ready', listener: () => void): SSHClient;
on(event: 'error', listener: (err: Error) => void): SSHClient;
on(event: 'close', listener: () => void): SSHClient;
+ /**
+ * OpenSSH's `UpdateHostKeys` announcement. ssh2 verifies the
+ * `hostkeys-prove-00@openssh.com` signatures before emitting, so these keys
+ * are proven to belong to the connected server.
+ */
+ on(event: 'hostkeys', listener: (keys: readonly { getPublicSSH(): Buffer; type: string }[]) => void): SSHClient;
removeListener(event: 'close', listener: () => void): SSHClient;
removeListener(event: 'error', listener: (err: Error) => void): SSHClient;
connect(config: ConnectConfig): void;
@@ -104,6 +121,31 @@ const LOG_PREFIX = '[SSHRemoteAgentHost]';
*/
const RECONNECT_RELAY_TIMEOUT_MS = 60_000;
+/** Opaque handle for the handshake deadline timer; see `_armHandshakeDeadline`. */
+type IHandshakeDeadlineHandle = ReturnType;
+
+/**
+ * Deadline for the parts of the handshake that involve no human: TCP connect,
+ * key exchange, and authentication. Kept short so an unreachable or stalled
+ * server fails promptly.
+ */
+const HANDSHAKE_TIMEOUT_MS = 30_000;
+
+/**
+ * Deadline that applies only while we are waiting on a person — a host key
+ * confirmation or a keyboard-interactive prompt.
+ *
+ * We manage the handshake deadline ourselves (ssh2's `readyTimeout` is
+ * disabled) because ssh2's timer covers the whole handshake and keeps running
+ * while `hostVerifier` awaits a verdict. Leaving it armed would abort the
+ * connection out from under a user doing exactly what the host key dialog asks
+ * — going to compare a fingerprint against another source — while simply
+ * raising it for the whole handshake would make an unreachable host take
+ * minutes to fail. So the deadline is short by default and only stretched for
+ * the interval a prompt is actually outstanding.
+ */
+const INTERACTIVE_TIMEOUT_MS = 300_000;
+
/**
* One entry in the queue of authentication attempts handed to ssh2's
* `authHandler`. Each attempt corresponds to one of the auth method shapes
@@ -676,6 +718,15 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
private readonly _onDidCancelEndpointSelection = this._register(new Emitter());
readonly onDidCancelEndpointSelection: Event = this._onDidCancelEndpointSelection.event;
+ private readonly _onDidRequestHostKeyVerification = this._register(new Emitter());
+ readonly onDidRequestHostKeyVerification: Event = this._onDidRequestHostKeyVerification.event;
+
+ private readonly _onDidCancelHostKeyVerification = this._register(new Emitter());
+ readonly onDidCancelHostKeyVerification: Event = this._onDidCancelHostKeyVerification.event;
+
+ private readonly _onDidAnnounceHostKeys = this._register(new Emitter());
+ readonly onDidAnnounceHostKeys: Event = this._onDidAnnounceHostKeys.event;
+
/**
* Pending keyboard-interactive prompts awaiting a response from the renderer.
* Keyed by `requestId`. Each entry can either finish the ssh2 prompt with
@@ -692,6 +743,18 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
private readonly _pendingEndpointSelections = new Map void>();
private _endpointSelectionCounter = 0;
+ /**
+ * Pending host key verifications awaiting a verdict from the renderer,
+ * keyed by `requestId`. Every entry must eventually be settled — leaving
+ * one unanswered suspends the SSH handshake until the deadline elapses.
+ *
+ * `onUserDenied` lets the owning connect attempt distinguish "the renderer
+ * refused this key" from any other handshake failure, so it can surface a
+ * clean error instead of ssh2's internal wording.
+ */
+ private readonly _pendingHostKeyRequests = new Map void; onUserDenied?: () => void }>();
+ private _hostKeyRequestCounter = 0;
+
private readonly _connections = this._register(new DisposableMap());
private _nativeRequire: NodeJS.Require | undefined;
@@ -1078,6 +1141,9 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
} catch (err) {
sshClient?.end();
+ if (!(err instanceof CancellationError)) {
+ this._logService.error(`${LOG_PREFIX} Failed to connect to ${displayHost}`, err);
+ }
throw err;
}
}
@@ -1271,11 +1337,14 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
config: ISSHAgentHostConfig,
connectionKey?: string,
): Promise {
+ const port = config.port ?? 22;
const connectConfig: ConnectConfig = {
host: config.host,
- port: config.port ?? 22,
+ port,
username: config.username,
- readyTimeout: 30_000,
+ // We enforce the handshake deadline ourselves so it can be stretched
+ // while a prompt is outstanding; see INTERACTIVE_TIMEOUT_MS.
+ readyTimeout: 0,
keepaliveInterval: 15_000,
};
@@ -1287,14 +1356,28 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
// the connect attempt fails or completes.
const liveKbiRequests = new Set();
let cancelConnectFromKbi: (() => void) | undefined;
+ // Forward reference into the connect promise below. Declared up here so
+ // every human-facing prompt can widen the handshake deadline while it
+ // is outstanding.
+ let armDeadline: ((ms: number) => void) | undefined;
+ // Once the user has answered, the human is out of the loop again, so
+ // the rest of the handshake goes back to the network-sized deadline.
+ const wrapPromptFinish = (finish: (value: T) => void) => (value: T) => {
+ armDeadline?.(HANDSHAKE_TIMEOUT_MS);
+ finish(value);
+ };
const kbiHandler: SSHKeyboardInteractivePromptHandler | undefined = attempts.some(a => a.type === 'keyboard-interactive')
? (name, instructions, prompts, finish) => {
- const requestId = this._handleKeyboardInteractive(connectionKey ?? displayHost, displayHost, config.username, name, instructions, prompts, finish, () => cancelConnectFromKbi?.());
+ // A human is now in the loop; don't hold them to the
+ // network-sized deadline while they find their password.
+ armDeadline?.(INTERACTIVE_TIMEOUT_MS);
+ const requestId = this._handleKeyboardInteractive(connectionKey ?? displayHost, displayHost, config.username, name, instructions, prompts, wrapPromptFinish(finish), () => cancelConnectFromKbi?.());
liveKbiRequests.add(requestId);
}
: undefined;
const keyPassphraseHandler: SSHKeyPassphrasePromptHandler | undefined = attempts.some(a => a.type === 'publickey' && a.encrypted)
? (keyPath, finish) => {
+ armDeadline?.(INTERACTIVE_TIMEOUT_MS);
const requestId = this._handleKeyboardInteractive(
connectionKey ?? displayHost,
displayHost,
@@ -1302,7 +1385,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
localize('sshKeyPassphraseName', "SSH Key Passphrase"),
'',
[{ prompt: localize('sshKeyPassphrasePrompt', "Enter passphrase for SSH key {0}.", keyPath), echo: false }],
- responses => finish(responses[0]),
+ wrapPromptFinish((responses: readonly string[]) => finish(responses[0])),
() => cancelConnectFromKbi?.(),
);
liveKbiRequests.add(requestId);
@@ -1317,9 +1400,9 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
for (const requestId of liveKbiRequests) {
// Pull the pending finish callback (if any) and invoke it with
// empty responses so ssh2 stops waiting on this attempt — without
- // this, ssh2 hangs until `readyTimeout` elapses when a connect
- // attempt is aborted mid-prompt. The renderer also gets notified
- // so it can dismiss any open quick-input UI.
+ // this, ssh2 hangs until the handshake deadline elapses when a
+ // connect attempt is aborted mid-prompt. The renderer also gets
+ // notified so it can dismiss any open quick-input UI.
const pending = this._pendingKbiRequests.get(requestId);
this._pendingKbiRequests.delete(requestId);
this._onDidCancelKeyboardInteractive.fire(requestId);
@@ -1342,17 +1425,87 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
}
}
+ // Verify the server's host key during key exchange. Without this, ssh2
+ // accepts any key from any server ("Host accepted by default"), which
+ // would let an on-path attacker impersonate the remote and collect the
+ // password typed into our own keyboard-interactive prompt. hostVerifier
+ // runs before authentication, so declining guarantees no credential or
+ // forwarded agent access ever reaches an unverified server.
+ //
+ // Note we deliberately do not set `hostHash`: that would make ssh2
+ // pre-hash the key and hand us a hex digest, discarding the raw blob we
+ // need to compare against `known_hosts` entries.
+ const liveHostKeyRequests = new Set();
+ // Set once the connect attempt settles, so a verification that is still
+ // gathering evidence at that moment can bail out instead of registering
+ // itself after cancellation has already swept the set.
+ let hostKeyVerificationAborted = false;
+ // Set when the renderer refuses a host key for this attempt, so the
+ // resulting handshake failure can be reported as what it actually is.
+ let hostKeyDenied = false;
+ const cancelLiveHostKeyRequests = () => {
+ hostKeyVerificationAborted = true;
+ for (const requestId of liveHostKeyRequests) {
+ const pending = this._pendingHostKeyRequests.get(requestId);
+ this._pendingHostKeyRequests.delete(requestId);
+ this._onDidCancelHostKeyVerification.fire(requestId);
+ // Fail closed: an aborted connect must never leave ssh2 waiting
+ // on a verdict until the deadline elapses.
+ pending?.verify(false);
+ }
+ liveHostKeyRequests.clear();
+ };
+ connectConfig.hostVerifier = (key: Buffer, verify: (permitted: boolean) => void) => {
+ void this._verifyHostKey(
+ connectionKey ?? displayHost,
+ displayHost,
+ config,
+ port,
+ key,
+ verify,
+ requestId => {
+ liveHostKeyRequests.add(requestId);
+ // A human is now in the loop; stop holding them to the
+ // network-sized deadline.
+ armDeadline?.(INTERACTIVE_TIMEOUT_MS);
+ return () => { hostKeyDenied = true; };
+ },
+ () => hostKeyVerificationAborted,
+ () => armDeadline?.(HANDSHAKE_TIMEOUT_MS),
+ );
+ };
+
const client = await this._createSSHClient();
return new Promise((resolve, reject) => {
let settled = false;
+ let deadlineTimer: IHandshakeDeadlineHandle | undefined;
+
+ const clearDeadline = () => {
+ this._clearHandshakeDeadline(deadlineTimer);
+ deadlineTimer = undefined;
+ };
+
+ // Replaces ssh2's `readyTimeout` (disabled above) so the window can
+ // be widened only for the interval a prompt is actually outstanding.
+ armDeadline = (ms: number) => {
+ if (settled) {
+ return;
+ }
+ clearDeadline();
+ deadlineTimer = this._armHandshakeDeadline(ms, () => {
+ rejectConnect(new Error(`SSH handshake to ${config.host} timed out`), true);
+ });
+ };
const resolveConnect = () => {
if (settled) {
return;
}
settled = true;
+ clearDeadline();
this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
cancelLiveKbiRequests();
+ cancelLiveHostKeyRequests();
resolve(client);
};
@@ -1361,7 +1514,9 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
return;
}
settled = true;
+ clearDeadline();
cancelLiveKbiRequests();
+ cancelLiveHostKeyRequests();
if (endClient) {
client.end();
}
@@ -1379,13 +1534,54 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
client.on('error', (err: Error) => {
this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
- rejectConnect(err, false);
+ // ssh2 reports a refused host key as "Host denied (verification
+ // failed)", which is both jargon and redundant — the host key
+ // UI has already told the user what happened.
+ rejectConnect(hostKeyDenied ? new SSHHostKeyDeniedError(displayHost) : err, false);
});
+ // A server can drop the connection cleanly mid-handshake (for
+ // example sshd refusing a session under MaxStartups), in which case
+ // ssh2 emits only 'end'/'close' with no 'error'. Without this the
+ // connect promise would never settle and any outstanding host key
+ // prompt would be left on screen forever.
+ client.on('close', () => {
+ rejectConnect(
+ hostKeyDenied
+ ? new SSHHostKeyDeniedError(displayHost)
+ : new Error(`SSH connection to ${config.host} closed before the handshake completed`),
+ false);
+ });
+
+ // A server may announce its full host key set over the
+ // already-authenticated channel (OpenSSH's UpdateHostKeys). ssh2
+ // completes the `hostkeys-prove` challenge and verifies the
+ // signatures before emitting, so these are safe to persist without
+ // prompting — this is what lets a legitimate key rotation be
+ // learned silently instead of surfacing as a scary mismatch later.
+ client.on('hostkeys', (keys: readonly { getPublicSSH(): Buffer; type: string }[]) => {
+ this._handleAnnouncedHostKeys(connectionKey ?? displayHost, config.host, port, keys);
+ });
+
+ armDeadline(HANDSHAKE_TIMEOUT_MS);
client.connect(connectConfig);
});
}
+ /**
+ * Arm the handshake deadline. Overridable so tests can observe how the
+ * window changes as prompts come and go without waiting on real timers.
+ */
+ protected _armHandshakeDeadline(ms: number, onExpired: () => void): IHandshakeDeadlineHandle {
+ return setTimeout(onExpired, ms);
+ }
+
+ protected _clearHandshakeDeadline(timer: IHandshakeDeadlineHandle | undefined): void {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+
protected async _createSSHClient(): Promise {
const nativeRequire = await this._getNativeRequire();
const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown };
@@ -1567,6 +1763,179 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
pending.finish(responses);
}
+ /**
+ * Read every `known_hosts` file that applies to `host` and return the
+ * parsed entries. Overridable so tests can supply entries without touching
+ * the developer's real SSH setup.
+ *
+ * Resolution deliberately goes through `ssh -G` rather than assuming
+ * `~/.ssh/known_hosts`, so a user who has redirected `UserKnownHostsFile`
+ * gets the files they actually configured. A failure here is not fatal: we
+ * fall back to no entries, which downgrades to a trust prompt rather than
+ * silently accepting an unverified key.
+ */
+ protected async _readKnownHostsEntries(host: string): Promise<{ entries: IKnownHostsEntry[]; strictHostKeyChecking: SSHStrictHostKeyChecking | undefined }> {
+ let resolved: ISSHResolvedConfig | undefined;
+ try {
+ resolved = await this.resolveSSHConfig(host);
+ } catch (err) {
+ this._logService.warn(`${LOG_PREFIX} Could not resolve SSH config for known_hosts lookup of ${host}: ${err}`);
+ }
+
+ const paths = [
+ ...(resolved?.userKnownHostsFiles ?? ['~/.ssh/known_hosts']),
+ ...(resolved?.globalKnownHostsFiles ?? []),
+ ];
+
+ const entries: IKnownHostsEntry[] = [];
+ for (const path of paths) {
+ const expanded = path.replace(/^~/, os.homedir());
+ try {
+ entries.push(...parseKnownHosts(await fsp.readFile(expanded, 'utf-8')));
+ } catch {
+ // Missing or unreadable known_hosts files are normal (most
+ // systems have no known_hosts2 and no global file).
+ }
+ }
+ return { entries, strictHostKeyChecking: resolved?.strictHostKeyChecking };
+ }
+
+ /**
+ * Decide whether a presented host key should be trusted, by gathering the
+ * evidence the renderer needs and asking it to apply policy.
+ *
+ * This process only collects facts — the fingerprint and what the user's
+ * `known_hosts` files say. The renderer owns the decision because it holds
+ * the trust store and the UI.
+ */
+ private async _verifyHostKey(
+ connectionKey: string,
+ displayHost: string,
+ config: ISSHAgentHostConfig,
+ port: number,
+ key: Buffer,
+ verify: (permitted: boolean) => void,
+ onRequest: (requestId: string) => (() => void) | void,
+ isAborted: () => boolean,
+ onPromptSettled: () => void,
+ ): Promise {
+ let settled = false;
+ let prompted = false;
+ const verifyOnce = (permitted: boolean) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (prompted) {
+ // The human is out of the loop; restore the network deadline so
+ // the rest of the handshake is not held to the long window.
+ onPromptSettled();
+ }
+ verify(permitted);
+ };
+
+ try {
+ const keyType = readHostKeyType(key);
+ if (!keyType) {
+ // A blob whose self-declared algorithm we cannot read is not
+ // something we can meaningfully show the user or compare, so
+ // refuse rather than prompting about an unidentifiable key.
+ this._logService.error(`${LOG_PREFIX} Rejecting malformed host key from ${displayHost}`);
+ verifyOnce(false);
+ return;
+ }
+
+ const fingerprint = computeHostKeyFingerprint(key);
+ const { entries, strictHostKeyChecking } = await this._readKnownHostsEntries(config.sshConfigHost ?? config.host);
+
+ // Gathering evidence is asynchronous, so the connect attempt may
+ // have failed while we were reading known_hosts. Registering now
+ // would leak a pending entry that nothing will ever settle, and
+ // would prompt the user about a connection that is already gone.
+ if (isAborted()) {
+ this._logService.info(`${LOG_PREFIX} Abandoning host key verification for ${displayHost}: connect attempt already settled`);
+ verifyOnce(false);
+ return;
+ }
+
+ const knownHostsMatch = matchKnownHosts(entries, config.host, port, keyType, key);
+ this._logService.info(`${LOG_PREFIX} Host key for ${displayHost}: ${keyType} ${fingerprint} (known_hosts: ${knownHostsMatch})`);
+
+ const requestId = `hostkey-${++this._hostKeyRequestCounter}`;
+ prompted = true;
+ const onUserDenied = onRequest(requestId) ?? undefined;
+ this._pendingHostKeyRequests.set(requestId, { verify: verifyOnce, onUserDenied });
+ this._onDidRequestHostKeyVerification.fire({
+ requestId,
+ connectionKey,
+ displayHost,
+ host: config.host,
+ port,
+ keyType,
+ fingerprint,
+ knownHostsMatch,
+ ...(strictHostKeyChecking ? { strictHostKeyChecking } : undefined),
+ userInitiated: config.userInitiated ?? true,
+ });
+ } catch (err) {
+ // Fail closed. Anything unexpected while gathering evidence must
+ // deny rather than accept, or a transient error becomes a way to
+ // bypass verification entirely.
+ this._logService.error(`${LOG_PREFIX} Host key verification failed for ${displayHost}`, err);
+ verifyOnce(false);
+ }
+ }
+
+ async respondHostKeyVerification(requestId: string, trusted: boolean): Promise {
+ const pending = this._pendingHostKeyRequests.get(requestId);
+ if (!pending) {
+ this._logService.warn(`${LOG_PREFIX} respondHostKeyVerification: no pending request for ${requestId}`);
+ return;
+ }
+ this._pendingHostKeyRequests.delete(requestId);
+ this._logService.info(`${LOG_PREFIX} Host key ${trusted ? 'accepted' : 'rejected'} for request ${requestId}`);
+ if (!trusted) {
+ // Let the connect attempt report this as a host key refusal rather
+ // than surfacing ssh2's "Host denied (verification failed)".
+ pending.onUserDenied?.();
+ }
+ pending.verify(trusted);
+ }
+
+ /**
+ * Surface host keys announced over an authenticated connection. ssh2 has
+ * already proven each key belongs to this server (it runs the
+ * `hostkeys-prove-00@openssh.com` challenge and verifies the signatures
+ * before emitting), so consumers may persist them without prompting.
+ */
+ private _handleAnnouncedHostKeys(
+ connectionKey: string,
+ host: string,
+ port: number,
+ keys: readonly { getPublicSSH(): Buffer; type: string }[],
+ ): void {
+ const announced: { keyType: string; fingerprint: string }[] = [];
+ for (const key of keys) {
+ try {
+ const blob = key.getPublicSSH();
+ const keyType = readHostKeyType(blob);
+ // Skip anything whose blob disagrees with its declared type
+ // (notably certificates, which ssh2 misparses) rather than
+ // persisting trust in a key we did not correctly understand.
+ if (keyType && keyType === key.type) {
+ announced.push({ keyType, fingerprint: computeHostKeyFingerprint(blob) });
+ }
+ } catch (err) {
+ this._logService.warn(`${LOG_PREFIX} Skipping unreadable announced host key for ${host}: ${err}`);
+ }
+ }
+ if (!announced.length) {
+ return;
+ }
+ this._logService.info(`${LOG_PREFIX} Server ${host} announced ${announced.length} proven host key(s)`);
+ this._onDidAnnounceHostKeys.fire({ connectionKey, host, port, keys: announced });
+ }
+
/**
* Ask the renderer to choose among live remote agent host endpoints (or
* to spawn a new dedicated one), mirroring the keyboard-interactive
@@ -1698,9 +2067,9 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
* `~/.vscode-cli{,-}/`), we fall back to the newest
* one rather than refusing to connect.
*
- * In dev/OSS builds with no commit, we keep the loose, non-pinned
- * behavior: install `~//` from the
- * `latest` URL, with a `--version`-based reuse check.
+ * In dev/OSS builds with no commit, we keep a loose, non-pinned install
+ * at `~//`. Existing CLIs self-update
+ * against the latest release before reuse.
*
* Returns the resolved CLI binary path to run.
*/
@@ -1800,9 +2169,15 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem
const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName);
this._logService.warn(`${LOG_PREFIX} Desktop has no product commit; falling back to non-pinned CLI install at ${cliBin}.`);
- const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true });
+ const updateExitCodeMarker = '__vscode_cli_update_exit_code__:';
+ const { code, stdout } = await sshExec(client, `${cliBin} --version && (${cliBin} update; update_code=$?; echo ${updateExitCodeMarker}$update_code; true)`, { ignoreExitCode: true });
if (code === 0) {
- this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin} (dev build, --version check passed)`);
+ const updateExitCodeLine = stdout.split('\n').find(line => line.startsWith(updateExitCodeMarker));
+ const updateExitCode = updateExitCodeLine === undefined ? undefined : Number.parseInt(updateExitCodeLine.slice(updateExitCodeMarker.length), 10);
+ if (updateExitCode !== undefined && updateExitCode !== 0) {
+ this._logService.warn(`${LOG_PREFIX} Could not refresh the dev-build remote CLI at ${cliBin}; reusing the existing executable: update exited ${updateExitCode}`);
+ }
+ this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin} (dev build, latest-version refresh attempted)`);
return cliBin;
}
diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts
index 64332c5e14a..3ae1cfcfbf3 100644
--- a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts
+++ b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts
@@ -13,6 +13,7 @@ import { raceTimeout } from '../../../base/common/async.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import {
+ createTunnelGatewaySelectionRejectedError,
ITunnelAgentHostMainService,
parseTunnelGatewayInventory,
parseTunnelGatewaySelectionResponse,
@@ -467,8 +468,10 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge
const response = parseTunnelGatewaySelectionResponse(responseText);
if (!response.ok) {
// The selected entry disappeared, or the CLI otherwise rejected
- // the selection (e.g. raced with another client). Close
- // everything rather than silently substituting another target.
+ // the selection (e.g. its socket was already gone). Close
+ // everything rather than silently substituting another target —
+ // but tag the error so the caller can tell this apart from an
+ // unreachable tunnel and pick a different endpoint itself.
try {
ws.close();
} catch {
@@ -479,7 +482,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge
} catch {
// ignore — best-effort cleanup
}
- throw new Error(`${LOG_PREFIX} ${response.error}`);
+ throw createTunnelGatewaySelectionRejectedError(`${LOG_PREFIX} ${response.error}`);
}
const connectionId = generateUuid();
diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts
index b1e1d83605b..8e1efa73d3b 100644
--- a/src/vs/platform/agentHost/node/webSocketTransport.ts
+++ b/src/vs/platform/agentHost/node/webSocketTransport.ts
@@ -14,6 +14,7 @@ import { generateUuid } from '../../../base/common/uuid.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
import { AhpJsonlLogger, getAhpLogByteLength } from '../common/ahpJsonlLogger.js';
+import { AgentHostTransportKind } from '../common/agentHostTelemetry.js';
import { JSON_RPC_PARSE_ERROR, type AhpServerNotification, type JsonRpcNotification, type JsonRpcParseErrorResponse, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
import type * as wsTypes from 'ws';
@@ -45,6 +46,7 @@ export interface IWebSocketServerOptions {
* Messages are serialized as JSON with URI revival.
*/
export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport {
+ readonly transportKind = AgentHostTransportKind.WebSocket;
private readonly _onMessage = this._register(new Emitter());
readonly onMessage = this._onMessage.event;
diff --git a/src/vs/platform/agentHost/test/browser/sshHostKeyTrustService.test.ts b/src/vs/platform/agentHost/test/browser/sshHostKeyTrustService.test.ts
new file mode 100644
index 00000000000..a1716054323
--- /dev/null
+++ b/src/vs/platform/agentHost/test/browser/sshHostKeyTrustService.test.ts
@@ -0,0 +1,151 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
+import { DisposableStore } from '../../../../base/common/lifecycle.js';
+import { InMemoryStorageService, StorageScope } from '../../../storage/common/storage.js';
+import {
+ parseTrustedHostKeys,
+ SSHHostKeyTrustService,
+ SSH_HOST_KEY_TRUST_STORAGE_KEY,
+} from '../../browser/sshHostKeyTrustService.js';
+
+suite('SSHHostKeyTrustService', () => {
+
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ function createService(store: Pick) {
+ const storageService = store.add(new InMemoryStorageService());
+ const service = store.add(new SSHHostKeyTrustService(storageService));
+ return { service, storageService };
+ }
+
+ test('stores, reads back and forgets host keys', () => {
+ const { service } = createService(disposables);
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1 });
+
+ const afterTrust = service.getTrustedKeys('example.com', 22);
+ // Host keys belong to a machine, so lookup must be case-insensitive in
+ // the same way hostnames are.
+ const mixedCase = service.getTrustedKeys('ExAmPlE.CoM', 22);
+ service.forgetHost('example.com', 22);
+
+ assert.deepStrictEqual(
+ {
+ afterTrust: afterTrust.map(k => `${k.keyType} ${k.fingerprint}`),
+ mixedCase: mixedCase.map(k => k.fingerprint),
+ afterForget: service.getTrustedKeys('example.com', 22).length,
+ },
+ {
+ afterTrust: ['ssh-ed25519 SHA256:aaa'],
+ mixedCase: ['SHA256:aaa'],
+ afterForget: 0,
+ });
+ });
+
+ test('keys hosts by port', () => {
+ const { service } = createService(disposables);
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1 });
+ service.trustHostKey('example.com', 2222, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:bbb', addedAt: 1 });
+
+ assert.deepStrictEqual(
+ {
+ default: service.getTrustedKeys('example.com', 22).map(k => k.fingerprint),
+ custom: service.getTrustedKeys('example.com', 2222).map(k => k.fingerprint),
+ listed: service.listTrustedHosts().map(h => `${h.host}:${h.port}`).sort(),
+ },
+ {
+ default: ['SHA256:aaa'],
+ custom: ['SHA256:bbb'],
+ listed: ['example.com:22', 'example.com:2222'],
+ });
+ });
+
+ test('a rotated key replaces its predecessor for the same algorithm', () => {
+ const { service } = createService(disposables);
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:old', addedAt: 1 });
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-rsa', fingerprint: 'SHA256:rsa', addedAt: 1 });
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:new', addedAt: 2 });
+
+ // The superseded ed25519 key must not remain trusted, or a rotation
+ // would leave the old key valid forever.
+ assert.deepStrictEqual(
+ service.getTrustedKeys('example.com', 22).map(k => `${k.keyType} ${k.fingerprint}`).sort(),
+ ['ssh-ed25519 SHA256:new', 'ssh-rsa SHA256:rsa']);
+ });
+
+ test('persists across service instances at application scope', () => {
+ const store = new DisposableStore();
+ const storageService = store.add(new InMemoryStorageService());
+ const first = store.add(new SSHHostKeyTrustService(storageService));
+ first.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1, alias: 'myhost' });
+
+ const second = store.add(new SSHHostKeyTrustService(storageService));
+ assert.deepStrictEqual(
+ second.getTrustedKeys('example.com', 22).map(k => ({ keyType: k.keyType, fingerprint: k.fingerprint, alias: k.alias })),
+ [{ keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', alias: 'myhost' }]);
+ store.dispose();
+ });
+
+ test('clears storage entirely when the last host is forgotten', () => {
+ const { service, storageService } = createService(disposables);
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1 });
+ service.forgetHost('example.com', 22);
+ assert.strictEqual(storageService.get(SSH_HOST_KEY_TRUST_STORAGE_KEY, StorageScope.APPLICATION), undefined);
+ });
+
+ test('fires a change event for the affected host', () => {
+ const { service } = createService(disposables);
+ const fired: string[] = [];
+ disposables.add(service.onDidChangeTrustedHosts(key => fired.push(key)));
+
+ service.trustHostKey('example.com', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1 });
+ service.forgetHost('example.com', 22);
+ // Forgetting an unknown host is a no-op and must not fire.
+ service.forgetHost('other.com', 22);
+
+ assert.deepStrictEqual(fired, ['example.com:22', 'example.com:22']);
+ });
+
+ suite('parseTrustedHostKeys', () => {
+ test('drops malformed entries without discarding the rest', () => {
+ const raw = JSON.stringify({
+ 'good.com:22': [{ keyType: 'ssh-ed25519', fingerprint: 'SHA256:aaa', addedAt: 1 }],
+ 'partial.com:22': [
+ { keyType: 'ssh-ed25519', fingerprint: 'SHA256:bbb', addedAt: 2 },
+ // Each of these is missing or has the wrong type for a
+ // required field. Trust must never be reconstructed from a
+ // partial record.
+ { keyType: 'ssh-rsa', fingerprint: 'SHA256:ccc' },
+ { keyType: '', fingerprint: 'SHA256:ddd', addedAt: 3 },
+ { keyType: 'ssh-rsa', addedAt: 4 },
+ 'not-an-object',
+ ],
+ 'empty.com:22': [],
+ 'wrong-shape.com:22': 'not-an-array',
+ });
+
+ const parsed = parseTrustedHostKeys(raw);
+ assert.deepStrictEqual(
+ {
+ hosts: [...parsed.keys()].sort(),
+ partial: parsed.get('partial.com:22')?.map(k => k.fingerprint),
+ },
+ { hosts: ['good.com:22', 'partial.com:22'], partial: ['SHA256:bbb'] });
+ });
+
+ test('returns empty for absent or invalid JSON', () => {
+ assert.deepStrictEqual(
+ {
+ undefinedRaw: parseTrustedHostKeys(undefined).size,
+ invalidJson: parseTrustedHostKeys('{not json').size,
+ array: parseTrustedHostKeys('[]').size,
+ nullValue: parseTrustedHostKeys('null').size,
+ },
+ { undefinedRaw: 0, invalidJson: 0, array: 0, nullValue: 0 });
+ });
+ });
+});
diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts
index 0e2b36dd296..62ff95364dd 100644
--- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts
+++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts
@@ -9,6 +9,7 @@ import { URI } from '../../../../base/common/uri.js';
import { Event } from '../../../../base/common/event.js';
import type { IDetailedDiffResult, IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js';
import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
+import type { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js';
import type { Message } from '../../common/state/sessionState.js';
export class TestSessionDatabase implements ISessionDatabase {
@@ -344,3 +345,22 @@ function createReference(object: T): IReference {
dispose: () => { },
};
}
+
+/**
+ * Recording {@link IAgentHostCheckpointService} double that captures
+ * {@link captureBaselineCheckpoint} invocations (session + resolved working
+ * directories) so tests can assert baseline capture on the fresh materialize
+ * path — and its absence on resume / subsequent sends. All other methods are
+ * no-ops, mirroring `NULL_CHECKPOINT_SERVICE`.
+ */
+export class RecordingCheckpointService implements IAgentHostCheckpointService {
+ declare readonly _serviceBrand: undefined;
+ readonly baselineCalls: { readonly session: string; readonly workingDirectories: readonly string[] | undefined }[] = [];
+ async captureBaselineCheckpoint(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise {
+ this.baselineCalls.push({ session: sessionUri.toString(), workingDirectories: workingDirectories?.map(w => w.toString()) });
+ }
+ async captureTurnCheckpoint(): Promise { }
+ async getTurnCheckpointPair(): Promise<{ parent: string; current: string } | undefined> { return undefined; }
+ async getBaselineCheckpoint(): Promise { return undefined; }
+ async deleteCheckpoints(): Promise { }
+}
diff --git a/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts
index adf8e7e9db0..c013eeadb82 100644
--- a/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts
+++ b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts
@@ -139,6 +139,9 @@ suite('SSH Config Parsing', () => {
identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'],
identityAgent: undefined,
forwardAgent: false,
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: undefined,
});
});
@@ -226,9 +229,63 @@ suite('SSH Config Parsing', () => {
identityFile: [],
identityAgent: undefined,
forwardAgent: false,
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: undefined,
});
});
+ test('splits the known_hosts path lists', () => {
+ // `ssh -G` emits these as one space-separated line, so treating the
+ // value as a single path would silently look in a bogus location.
+ const output = [
+ 'userknownhostsfile /home/u/.ssh/known_hosts /home/u/.ssh/known_hosts2',
+ 'globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2',
+ ].join('\n');
+
+ const result = parseSSHGOutput(output);
+ assert.deepStrictEqual(
+ { user: result.userKnownHostsFiles, global: result.globalKnownHostsFiles },
+ {
+ user: ['/home/u/.ssh/known_hosts', '/home/u/.ssh/known_hosts2'],
+ global: ['/etc/ssh/ssh_known_hosts', '/etc/ssh/ssh_known_hosts2'],
+ });
+ });
+
+ test('honors quoting in known_hosts path lists', () => {
+ const output = 'userknownhostsfile "/home/my user/.ssh/known_hosts" /home/u/other';
+ assert.deepStrictEqual(
+ parseSSHGOutput(output).userKnownHostsFiles,
+ ['/home/my user/.ssh/known_hosts', '/home/u/other']);
+ });
+
+ test('parses recognized StrictHostKeyChecking values and ignores others', () => {
+ const parse = (value: string) => parseSSHGOutput(`stricthostkeychecking ${value}`).strictHostKeyChecking;
+ assert.deepStrictEqual(
+ {
+ ask: parse('ask'),
+ acceptNew: parse('accept-new'),
+ yes: parse('yes'),
+ no: parse('no'),
+ off: parse('off'),
+ uppercase: parse('ASK'),
+ // An unrecognized value must not be passed through as if it
+ // were a policy we understand.
+ bogus: parse('maybe'),
+ absent: parseSSHGOutput('').strictHostKeyChecking,
+ },
+ {
+ ask: 'ask',
+ acceptNew: 'accept-new',
+ yes: 'yes',
+ no: 'no',
+ off: 'off',
+ uppercase: 'ask',
+ bogus: undefined,
+ absent: undefined,
+ });
+ });
+
test('handles values with spaces', () => {
const output = 'hostname my host with spaces\nport 22';
const result = parseSSHGOutput(output);
diff --git a/src/vs/platform/agentHost/test/common/sshHostKeyPolicy.test.ts b/src/vs/platform/agentHost/test/common/sshHostKeyPolicy.test.ts
new file mode 100644
index 00000000000..55dfd1e7c5f
--- /dev/null
+++ b/src/vs/platform/agentHost/test/common/sshHostKeyPolicy.test.ts
@@ -0,0 +1,175 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
+import { decideHostKeyTrust, type SSHHostKeyDecision } from '../../common/sshHostKeyPolicy.js';
+import type { ISSHTrustedHostKey } from '../../common/sshHostKeyTrust.js';
+import type { ISSHHostKeyVerificationRequest, SSHKnownHostsMatch, SSHStrictHostKeyChecking } from '../../common/sshRemoteAgentHost.js';
+
+const FINGERPRINT = 'SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+const OTHER_FINGERPRINT = 'SHA256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
+
+function makeRequest(overrides: {
+ knownHostsMatch?: SSHKnownHostsMatch;
+ strictHostKeyChecking?: SSHStrictHostKeyChecking;
+ userInitiated?: boolean;
+} = {}): ISSHHostKeyVerificationRequest {
+ return {
+ requestId: 'hostkey-1',
+ connectionKey: 'ssh:testhost',
+ displayHost: 'testhost',
+ host: 'test.example.com',
+ port: 22,
+ keyType: 'ssh-ed25519',
+ fingerprint: FINGERPRINT,
+ knownHostsMatch: overrides.knownHostsMatch ?? 'unknown',
+ ...(overrides.strictHostKeyChecking ? { strictHostKeyChecking: overrides.strictHostKeyChecking } : undefined),
+ userInitiated: overrides.userInitiated ?? true,
+ };
+}
+
+function trusted(fingerprint: string, keyType = 'ssh-ed25519'): ISSHTrustedHostKey[] {
+ return [{ keyType, fingerprint, addedAt: 1 }];
+}
+
+/** Reduce a decision to a compact string so whole tables can be asserted at once. */
+function summarize(decision: SSHHostKeyDecision): string {
+ return decision.kind === 'trust'
+ ? `trust(${decision.reason}${decision.persist ? ',persist' : ''})`
+ : `${decision.kind}(${decision.reason})`;
+}
+
+suite('sshHostKeyPolicy', () => {
+
+ ensureNoDisposablesAreLeakedInTestSuite();
+
+ test('decides from the trust store first', () => {
+ assert.deepStrictEqual(
+ {
+ storedMatch: summarize(decideHostKeyTrust(makeRequest(), trusted(FINGERPRINT))),
+ storedDiffers: summarize(decideHostKeyTrust(makeRequest(), trusted(OTHER_FINGERPRINT))),
+ // A stored entry for a *different* algorithm says nothing about
+ // this key, so it must not suppress the prompt.
+ storedOtherKeyType: summarize(decideHostKeyTrust(makeRequest(), trusted(OTHER_FINGERPRINT, 'ssh-rsa'))),
+ },
+ {
+ storedMatch: 'trust(stored)',
+ storedDiffers: 'deny(mismatch)',
+ storedOtherKeyType: 'prompt(unknown)',
+ });
+ });
+
+ test('falls back to known_hosts when nothing is stored', () => {
+ const decide = (knownHostsMatch: SSHKnownHostsMatch) =>
+ summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch }), []));
+ assert.deepStrictEqual(
+ {
+ match: decide('match'),
+ mismatch: decide('mismatch'),
+ revoked: decide('revoked'),
+ caOnly: decide('ca-only'),
+ unknown: decide('unknown'),
+ },
+ {
+ // A known_hosts hit is copied into our store so later decisions
+ // no longer depend on re-reading the user's files.
+ match: 'trust(known-hosts,persist)',
+ mismatch: 'deny(mismatch)',
+ revoked: 'deny(revoked)',
+ caOnly: 'prompt(ca-only)',
+ unknown: 'prompt(unknown)',
+ });
+ });
+
+ test('revocation overrides a stored trust entry', () => {
+ assert.strictEqual(
+ summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch: 'revoked' }), trusted(FINGERPRINT))),
+ 'deny(revoked)');
+ });
+
+ test('revocation overrides even a StrictHostKeyChecking opt-out', () => {
+ // Verified against OpenSSH 9.9: with StrictHostKeyChecking=no it still
+ // reports "REVOKED HOST KEY DETECTED" and disables password auth,
+ // keyboard-interactive auth and agent forwarding. Disabling host key
+ // checking means "I accept unknown keys", never "I accept keys I have
+ // explicitly revoked".
+ assert.deepStrictEqual(
+ {
+ no: summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch: 'revoked', strictHostKeyChecking: 'no' }), [])),
+ off: summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch: 'revoked', strictHostKeyChecking: 'off' }), [])),
+ },
+ { no: 'deny(revoked)', off: 'deny(revoked)' });
+ });
+
+ test('a stored key wins over a disagreeing known_hosts file', () => {
+ // Our store is authoritative for hosts already connected to, so a
+ // known_hosts entry that agrees with the server must not silently
+ // override a key the user previously accepted.
+ assert.strictEqual(
+ summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch: 'match' }), trusted(OTHER_FINGERPRINT))),
+ 'deny(mismatch)');
+ });
+
+ test('honors StrictHostKeyChecking', () => {
+ const decide = (strictHostKeyChecking: SSHStrictHostKeyChecking, knownHostsMatch: SSHKnownHostsMatch = 'unknown') =>
+ summarize(decideHostKeyTrust(makeRequest({ strictHostKeyChecking, knownHostsMatch }), []));
+ assert.deepStrictEqual(
+ {
+ ask: decide('ask'),
+ acceptNewUnknown: decide('accept-new'),
+ yesUnknown: decide('yes'),
+ no: decide('no'),
+ off: decide('off'),
+ // The opt-out covers *unknown* keys only. Verified against
+ // OpenSSH 9.9: with StrictHostKeyChecking=no and a changed key
+ // it warns and disables password auth, keyboard-interactive
+ // auth and agent forwarding. We refuse outright instead.
+ noWithMismatch: decide('no', 'mismatch'),
+ offWithMismatch: decide('off', 'mismatch'),
+ // A stored key that disagrees is refused under the opt-out too.
+ noWithStoredMismatch: summarize(decideHostKeyTrust(
+ makeRequest({ strictHostKeyChecking: 'no', knownHostsMatch: 'unknown' }),
+ trusted(OTHER_FINGERPRINT))),
+ // accept-new only relaxes *unknown* hosts; a changed key still
+ // hard-fails, matching OpenSSH.
+ acceptNewMismatch: decide('accept-new', 'mismatch'),
+ acceptNewRevoked: decide('accept-new', 'revoked'),
+ },
+ {
+ ask: 'prompt(unknown)',
+ acceptNewUnknown: 'trust(strict-accept-new,persist)',
+ yesUnknown: 'deny(strict-yes)',
+ no: 'trust(strict-disabled)',
+ off: 'trust(strict-disabled)',
+ noWithMismatch: 'deny(mismatch)',
+ offWithMismatch: 'deny(mismatch)',
+ noWithStoredMismatch: 'deny(mismatch)',
+ acceptNewMismatch: 'deny(mismatch)',
+ acceptNewRevoked: 'deny(revoked)',
+ });
+ });
+
+ test('never prompts during a background reconnect', () => {
+ const decide = (knownHostsMatch: SSHKnownHostsMatch, keys: ISSHTrustedHostKey[] = []) =>
+ summarize(decideHostKeyTrust(makeRequest({ knownHostsMatch, userInitiated: false }), keys));
+ assert.deepStrictEqual(
+ {
+ // An unknown key on a silent reconnect is declined rather than
+ // raising a modal the user never asked for.
+ unknown: decide('unknown'),
+ caOnly: decide('ca-only'),
+ // Already-trusted hosts still reconnect without interaction.
+ stored: decide('unknown', trusted(FINGERPRINT)),
+ knownHosts: decide('match'),
+ },
+ {
+ unknown: 'deny(not-user-initiated)',
+ caOnly: 'deny(not-user-initiated)',
+ stored: 'trust(stored)',
+ knownHosts: 'trust(known-hosts,persist)',
+ });
+ });
+});
diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts
index ecc231465ad..eb3948594d0 100644
--- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts
+++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts
@@ -61,6 +61,7 @@ const syncTestConfigurationNode = {
};
import type { Implementation } from '../../common/state/protocol/common/commands.js';
import { agentsWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js';
+import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js';
type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest;
type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined;
@@ -110,6 +111,10 @@ function findRootConfigValue(messages: readonly ProtocolTransportMessage[], conf
}
class TestProtocolTransport extends Disposable implements IProtocolTransport {
+ constructor(readonly clientConnectionKind?: AgentHostClientConnectionKind) {
+ super();
+ }
+
private readonly _onMessage = this._register(new Emitter());
readonly onMessage = this._onMessage.event;
@@ -815,7 +820,7 @@ suite('RemoteAgentHostProtocolClient', () => {
});
test('initialize handshake includes protocol version and client info', async () => {
- const transport = disposables.add(new TestClientProtocolTransport());
+ const transport = disposables.add(new TestClientProtocolTransport(AgentHostClientConnectionKind.DevTunnel));
const clientInfo = agentsWindowAgentHostClientInfo;
const { client } = createClient(transport, undefined, undefined, undefined, undefined, 'renderer-client-id', clientInfo);
const connectPromise = client.connect();
@@ -829,17 +834,19 @@ suite('RemoteAgentHostProtocolClient', () => {
const sent = transport.sentMessages[0] as JsonRpcRequest;
assert.strictEqual(sent.method, 'initialize');
- const params = sent.params as { protocolVersions: readonly string[]; clientId: string; clientInfo?: Implementation };
+ const params = sent.params as { protocolVersions: readonly string[]; clientId: string; clientInfo?: Implementation; _meta?: Record };
assert.deepStrictEqual({
protocolVersions: params.protocolVersions,
clientId: params.clientId,
clientInfo: params.clientInfo,
+ _meta: params._meta,
}, {
// Every negotiable version is offered so an older host can negotiate down,
// newest first so a current host still picks it.
protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
clientId: 'renderer-client-id',
clientInfo,
+ _meta: { 'vscode.clientConnectionKind': 'dev_tunnel' },
});
assert.strictEqual(params.protocolVersions[0], PROTOCOL_VERSION);
diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts
index e0d937b582f..b006226d67f 100644
--- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts
+++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts
@@ -14,8 +14,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../log/common/log.js';
import { IConfigurationService } from '../../../configuration/common/configuration.js';
-import { IDialogService } from '../../../dialogs/common/dialogs.js';
-import { INotificationService, type INotificationHandle } from '../../../notification/common/notification.js';
+import { IConfirmation, IDialogService } from '../../../dialogs/common/dialogs.js';
+import { INotificationService, Severity, type INotification, type INotificationHandle } from '../../../notification/common/notification.js';
import { TestNotificationService } from '../../../notification/test/common/testNotificationService.js';
import { IProductService } from '../../../product/common/productService.js';
@@ -25,12 +25,17 @@ import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHo
import type { IAgentConnection } from '../../common/agentService.js';
import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from '../../common/state/sessionProtocol.js';
import { IRemoteAgentHostLocationPreferenceService, type RemoteAgentHostLocationPreference } from '../../common/remoteAgentHostLocationPreference.js';
+import { ISSHHostKeyTrustService } from '../../common/sshHostKeyTrust.js';
+import { SSHHostKeyTrustService } from '../../browser/sshHostKeyTrustService.js';
+import { InMemoryStorageService } from '../../../storage/common/storage.js';
import type {
ISSHAgentHostConfig,
ISSHConnectResult,
ISSHEndpointCandidate,
ISSHEndpointSelection,
ISSHEndpointSelectionRequest,
+ ISSHHostKeyVerificationRequest,
+ ISSHHostKeysAnnouncement,
ISSHKeyboardInteractiveRequest,
ISSHResolvedConfig,
ISSHRemoteAgentHostMainService,
@@ -79,6 +84,45 @@ class MockSSHMainService {
private readonly _onDidCancelEndpointSelection = new Emitter();
readonly onDidCancelEndpointSelection = this._onDidCancelEndpointSelection.event;
+ private readonly _onDidRequestHostKeyVerification = new Emitter();
+ readonly onDidRequestHostKeyVerification = this._onDidRequestHostKeyVerification.event;
+
+ private readonly _onDidCancelHostKeyVerification = new Emitter();
+ readonly onDidCancelHostKeyVerification = this._onDidCancelHostKeyVerification.event;
+
+ private readonly _onDidAnnounceHostKeys = new Emitter();
+ readonly onDidAnnounceHostKeys = this._onDidAnnounceHostKeys.event;
+
+ readonly hostKeyResponses: Array<{ requestId: string; trusted: boolean }> = [];
+ private readonly _hostKeyResponseWaiters: DeferredPromise[] = [];
+
+ async respondHostKeyVerification(requestId: string, trusted: boolean): Promise {
+ this.hostKeyResponses.push({ requestId, trusted });
+ this._hostKeyResponseWaiters.splice(0).forEach(waiter => waiter.complete());
+ }
+
+ /** Test helper: fire a host key verification request as the shared process would. */
+ fireHostKeyVerificationRequest(request: ISSHHostKeyVerificationRequest): void {
+ this._onDidRequestHostKeyVerification.fire(request);
+ }
+
+ /** Test helper: cancel a host key verification as the shared process would. */
+ fireHostKeyVerificationCancel(requestId: string): void {
+ this._onDidCancelHostKeyVerification.fire(requestId);
+ }
+
+ /** Test helper: fire a host key announcement as the shared process would. */
+ fireHostKeysAnnouncement(announcement: ISSHHostKeysAnnouncement): void {
+ this._onDidAnnounceHostKeys.fire(announcement);
+ }
+
+ /** Test helper: resolves once {@link respondHostKeyVerification} is next called. */
+ waitForHostKeyResponse(): Promise {
+ const deferred = new DeferredPromise();
+ this._hostKeyResponseWaiters.push(deferred);
+ return deferred.p;
+ }
+
readonly endpointSelectionResponses: Array<{ requestId: string; selection: ISSHEndpointSelection | undefined }> = [];
private readonly _endpointSelectionResponseWaiters: DeferredPromise[] = [];
@@ -148,7 +192,7 @@ class MockSSHMainService {
async ensureUserSSHConfig(): Promise { return URI.file('/tmp/ssh-config'); }
async listSSHConfigFiles(): Promise { return [URI.file('/tmp/ssh-config')]; }
async resolveSSHConfig(_host: string): Promise {
- return { hostname: '', user: undefined, port: 22, identityFile: [], identityAgent: undefined, forwardAgent: false };
+ return { hostname: '', user: undefined, port: 22, identityFile: [], identityAgent: undefined, forwardAgent: false, userKnownHostsFiles: [], globalKnownHostsFiles: [], strictHostKeyChecking: undefined };
}
dispose(): void {
@@ -161,6 +205,9 @@ class MockSSHMainService {
this._onDidCancelKeyboardInteractive.dispose();
this._onDidRequestEndpointSelection.dispose();
this._onDidCancelEndpointSelection.dispose();
+ this._onDidRequestHostKeyVerification.dispose();
+ this._onDidCancelHostKeyVerification.dispose();
+ this._onDidAnnounceHostKeys.dispose();
}
}
@@ -270,10 +317,17 @@ class TestConfigurationService {
/** Captures every message passed to `info()` so tests can assert on the SSH failover notification. */
class CapturingNotificationService extends TestNotificationService {
readonly infoMessages: string[] = [];
+ readonly notifications: INotification[] = [];
+
override info(message: string): INotificationHandle {
this.infoMessages.push(message);
return super.info(message);
}
+
+ override notify(notification: INotification): INotificationHandle {
+ this.notifications.push(notification);
+ return super.notify(notification);
+ }
}
/** In-memory stand-in for {@link IRemoteAgentHostLocationPreferenceService}, keyed the same way as the real storage-backed implementation. */
@@ -311,6 +365,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => {
let service: SSHRemoteAgentHostService;
let quickInputServiceStub: Partial;
let locationPreferenceService: TestRemoteAgentHostLocationPreferenceService;
+ let hostKeyTrustService: SSHHostKeyTrustService;
setup(() => {
mainService = new MockSSHMainService();
@@ -338,6 +393,8 @@ suite('SSHRemoteAgentHostService (renderer)', () => {
prompt: (() => { throw new Error('unexpected dialogService.prompt call'); }) as unknown as IDialogService['prompt'],
} as Partial);
instantiationService.stub(IProductService, { _serviceBrand: undefined, nameShort: 'Test Product' } as IProductService);
+ hostKeyTrustService = disposables.add(new SSHHostKeyTrustService(disposables.add(new InMemoryStorageService())));
+ instantiationService.stub(ISSHHostKeyTrustService, hostKeyTrustService as Partial);
const clientWaiters: DeferredPromise[] = [];
waitForClient = (index: number): Promise => {
@@ -754,6 +811,7 @@ suite('SSHRemoteAgentHostService endpoint selection preference (renderer)', () =
locationPreferenceService = disposables.add(new TestRemoteAgentHostLocationPreferenceService());
instantiationService.stub(IRemoteAgentHostLocationPreferenceService, locationPreferenceService as Partial);
+ instantiationService.stub(ISSHHostKeyTrustService, disposables.add(new SSHHostKeyTrustService(disposables.add(new InMemoryStorageService()))) as Partial);
// Default to throwing so any test that doesn't expect the modal to
// appear fails loudly if the implementation shows it unexpectedly.
@@ -990,3 +1048,377 @@ suite('SSHRemoteAgentHostService endpoint selection preference (renderer)', () =
assert.strictEqual(locationPreferenceService.getPreference('ssh:other.example'), 'dedicated');
});
});
+
+suite('SSHRemoteAgentHostService host key verification (renderer)', () => {
+
+ const disposables = new DisposableStore();
+ let mainService: MockSSHMainService;
+ let hostKeyTrustService: SSHHostKeyTrustService;
+ let notificationService: CapturingNotificationService;
+ let confirmResult: boolean;
+ let confirmCalls: number;
+ /** When set, the confirm dialog blocks on this until the test releases it. */
+ let confirmGate: (() => Promise) | undefined;
+ let inFlightVerifications: Promise[];
+ /** The options the last confirm dialog was opened with. */
+ let lastConfirmOptions: IConfirmation | undefined;
+
+ setup(() => {
+ mainService = disposables.add(new MockSSHMainService());
+ const sharedProcessService: Partial = {
+ getChannel: () => asChannel(mainService),
+ };
+
+ const instantiationService = disposables.add(new TestInstantiationService());
+ instantiationService.stub(ILogService, new NullLogService());
+ instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial);
+ instantiationService.stub(IQuickInputService, {} as Partial);
+ instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService);
+ instantiationService.stub(IRemoteAgentHostService, disposables.add(new MockRemoteAgentHostService()) as Partial);
+ notificationService = new CapturingNotificationService();
+ instantiationService.stub(INotificationService, notificationService as Partial);
+ instantiationService.stub(ISSHRelayClientFactory, {
+ createClient: () => disposables.add(new MockProtocolClient()) as unknown as RemoteAgentHostProtocolClient,
+ });
+ instantiationService.stub(IRemoteAgentHostLocationPreferenceService, disposables.add(new TestRemoteAgentHostLocationPreferenceService()) as Partial);
+ instantiationService.stub(IProductService, { _serviceBrand: undefined, nameShort: 'Test Product' } as IProductService);
+
+ confirmResult = false;
+ confirmCalls = 0;
+ confirmGate = undefined;
+ lastConfirmOptions = undefined;
+ inFlightVerifications = [];
+ instantiationService.stub(IDialogService, {
+ confirm: (async (confirmation: IConfirmation) => {
+ confirmCalls++;
+ lastConfirmOptions = confirmation;
+ if (confirmGate) {
+ await confirmGate();
+ }
+ return { confirmed: confirmResult };
+ }) as unknown as IDialogService['confirm'],
+ } as Partial);
+
+ hostKeyTrustService = disposables.add(new SSHHostKeyTrustService(disposables.add(new InMemoryStorageService())));
+ instantiationService.stub(ISSHHostKeyTrustService, hostKeyTrustService as Partial);
+
+ // Subclassed so tests can await the real handler settling rather than
+ // sleeping for a fixed interval, which is load-dependent and flaky.
+ class TestableService extends SSHRemoteAgentHostService {
+ protected override _trackHostKeyVerification(handled: Promise): void {
+ inFlightVerifications.push(handled);
+ }
+ }
+ disposables.add(instantiationService.createInstance(TestableService));
+ });
+
+ teardown(() => disposables.clear());
+ ensureNoDisposablesAreLeakedInTestSuite();
+
+ /** Settles once every verification the test has triggered has finished. */
+ async function settleVerifications(): Promise {
+ while (inFlightVerifications.length) {
+ await Promise.all(inFlightVerifications.splice(0));
+ }
+ }
+
+ const FINGERPRINT = 'SHA256:testfingerprintaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+ function makeHostKeyRequest(overrides: Partial = {}): ISSHHostKeyVerificationRequest {
+ return {
+ requestId: 'hostkey-1',
+ connectionKey: 'ssh:remote.example',
+ displayHost: 'remote.example',
+ host: 'remote.example',
+ port: 22,
+ keyType: 'ssh-ed25519',
+ fingerprint: FINGERPRINT,
+ knownHostsMatch: 'unknown',
+ userInitiated: true,
+ ...overrides,
+ };
+ }
+
+ async function fireAndWait(request: ISSHHostKeyVerificationRequest): Promise {
+ const responded = mainService.waitForHostKeyResponse();
+ mainService.fireHostKeyVerificationRequest(request);
+ await responded;
+ }
+
+ test('prompts for an unknown host and persists on accept', async () => {
+ confirmResult = true;
+ await fireAndWait(makeHostKeyRequest());
+
+ assert.deepStrictEqual(
+ {
+ responses: mainService.hostKeyResponses,
+ confirmCalls,
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => `${k.keyType} ${k.fingerprint}`),
+ },
+ {
+ responses: [{ requestId: 'hostkey-1', trusted: true }],
+ confirmCalls: 1,
+ stored: ['ssh-ed25519 SHA256:testfingerprintaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
+ });
+ });
+
+ test('declining the prompt refuses the key and stores nothing', async () => {
+ confirmResult = false;
+ await fireAndWait(makeHostKeyRequest());
+
+ assert.deepStrictEqual(
+ {
+ responses: mainService.hostKeyResponses,
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).length,
+ },
+ { responses: [{ requestId: 'hostkey-1', trusted: false }], stored: 0 });
+ });
+
+ test('an already-trusted key connects silently', async () => {
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: FINGERPRINT, addedAt: 1 });
+ await fireAndWait(makeHostKeyRequest());
+
+ assert.deepStrictEqual(
+ { responses: mainService.hostKeyResponses, confirmCalls },
+ { responses: [{ requestId: 'hostkey-1', trusted: true }], confirmCalls: 0 });
+ });
+
+ test('a changed key is refused with no way to click through', async () => {
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:theoldkey', addedAt: 1 });
+ await fireAndWait(makeHostKeyRequest());
+
+ const notified = notificationService.notifications.at(-1);
+ assert.deepStrictEqual(
+ {
+ responses: mainService.hostKeyResponses,
+ // No dialog at all: recovering requires explicitly forgetting
+ // the host, so a possible impersonation can't be waved away.
+ confirmCalls,
+ severity: notified?.severity,
+ hasForgetAction: !!notified?.actions?.primary?.length,
+ // The old key must remain stored until the user forgets it.
+ stillStored: hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => k.fingerprint),
+ },
+ {
+ responses: [{ requestId: 'hostkey-1', trusted: false }],
+ confirmCalls: 0,
+ severity: Severity.Error,
+ hasForgetAction: true,
+ stillStored: ['SHA256:theoldkey'],
+ });
+ });
+
+ test('a known_hosts mismatch or revocation offers no forget action', async () => {
+ // "Forget Saved Host Key" only clears *our* store. When the conflict
+ // lives in the user's own known_hosts file, forgetting would change
+ // nothing and the very same error would reappear on the next connect,
+ // so the message points at the file that actually decides instead.
+ await fireAndWait(makeHostKeyRequest({ knownHostsMatch: 'mismatch' }));
+ const fromKnownHosts = notificationService.notifications.at(-1);
+
+ await fireAndWait(makeHostKeyRequest({ requestId: 'hostkey-2', knownHostsMatch: 'revoked' }));
+ const fromRevoked = notificationService.notifications.at(-1);
+
+ assert.deepStrictEqual(
+ {
+ knownHostsHasForget: !!fromKnownHosts?.actions?.primary?.length,
+ knownHostsMentionsFile: !!fromKnownHosts?.message.toString().includes('known_hosts'),
+ revokedHasForget: !!fromRevoked?.actions?.primary?.length,
+ revokedMentionsFile: !!fromRevoked?.message.toString().includes('known_hosts'),
+ responses: mainService.hostKeyResponses,
+ },
+ {
+ knownHostsHasForget: false,
+ knownHostsMentionsFile: true,
+ revokedHasForget: false,
+ revokedMentionsFile: true,
+ responses: [
+ { requestId: 'hostkey-1', trusted: false },
+ { requestId: 'hostkey-2', trusted: false },
+ ],
+ });
+ });
+
+ test('the forget action clears the stored key so the next connect can re-verify', async () => {
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: 'SHA256:theoldkey', addedAt: 1 });
+ await fireAndWait(makeHostKeyRequest());
+
+ await notificationService.notifications.at(-1)?.actions?.primary?.[0].run();
+ assert.strictEqual(hostKeyTrustService.getTrustedKeys('remote.example', 22).length, 0);
+ });
+
+ test('a known_hosts match is trusted silently and copied into the store', async () => {
+ await fireAndWait(makeHostKeyRequest({ knownHostsMatch: 'match' }));
+
+ assert.deepStrictEqual(
+ {
+ responses: mainService.hostKeyResponses,
+ confirmCalls,
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => k.fingerprint),
+ },
+ {
+ responses: [{ requestId: 'hostkey-1', trusted: true }],
+ confirmCalls: 0,
+ stored: [FINGERPRINT],
+ });
+ });
+
+ test('a revoked key is refused', async () => {
+ await fireAndWait(makeHostKeyRequest({ knownHostsMatch: 'revoked' }));
+ assert.deepStrictEqual(
+ { responses: mainService.hostKeyResponses, confirmCalls },
+ { responses: [{ requestId: 'hostkey-1', trusted: false }], confirmCalls: 0 });
+ });
+
+ test('a background reconnect never opens a dialog', async () => {
+ await fireAndWait(makeHostKeyRequest({ userInitiated: false }));
+ assert.deepStrictEqual(
+ { responses: mainService.hostKeyResponses, confirmCalls },
+ { responses: [{ requestId: 'hostkey-1', trusted: false }], confirmCalls: 0 });
+ });
+
+ test('StrictHostKeyChecking accept-new trusts unknown hosts without prompting', async () => {
+ await fireAndWait(makeHostKeyRequest({ strictHostKeyChecking: 'accept-new' }));
+ assert.deepStrictEqual(
+ {
+ responses: mainService.hostKeyResponses,
+ confirmCalls,
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).length,
+ },
+ { responses: [{ requestId: 'hostkey-1', trusted: true }], confirmCalls: 0, stored: 1 });
+ });
+
+ test('a prompt for a connection that dies is dismissed, and a late answer grants nothing', async () => {
+ // The dialog is opened with a cancellation token so it tears itself
+ // down when the connection drops, rather than stranding the user with
+ // a question about a connection that no longer exists. Answering it
+ // late must also be inert.
+ let releaseDialog = () => { };
+ const dialogShown = new Promise(resolveShown => {
+ confirmGate = () => {
+ resolveShown();
+ return new Promise(resolve => { releaseDialog = resolve; });
+ };
+ });
+ confirmResult = true;
+
+ mainService.fireHostKeyVerificationRequest(makeHostKeyRequest());
+ await dialogShown;
+ const dialogToken = lastConfirmOptions?.token;
+ const dismissedBeforeCancel = dialogToken?.isCancellationRequested;
+ // The connection drops while the user is still looking at the dialog.
+ mainService.fireHostKeyVerificationCancel('hostkey-1');
+ const dismissedAfterCancel = dialogToken?.isCancellationRequested;
+ releaseDialog();
+ await settleVerifications();
+
+ assert.deepStrictEqual(
+ {
+ // The dialog is handed a live token that is cancelled when the
+ // connection dies, which is what dismisses it.
+ dismissedBeforeCancel,
+ dismissedAfterCancel,
+ // And a late "Connect" still grants nothing.
+ responses: mainService.hostKeyResponses,
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).length,
+ },
+ { dismissedBeforeCancel: false, dismissedAfterCancel: true, responses: [], stored: 0 });
+ });
+
+ test('learns a rotated key announced over an authenticated connection', async () => {
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: FINGERPRINT, addedAt: 1 });
+ // Establish a session whose host key is itself trusted — that is what
+ // entitles the server to tell us about its other keys.
+ await fireAndWait(makeHostKeyRequest());
+
+ mainService.fireHostKeysAnnouncement({
+ connectionKey: 'ssh:remote.example',
+ host: 'remote.example',
+ port: 22,
+ keys: [
+ { keyType: 'ssh-ed25519', fingerprint: 'SHA256:rotated' },
+ { keyType: 'ssh-rsa', fingerprint: 'SHA256:rsakey' },
+ ],
+ });
+
+ assert.deepStrictEqual(
+ hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => `${k.keyType} ${k.fingerprint}`).sort(),
+ ['ssh-ed25519 SHA256:rotated', 'ssh-rsa SHA256:rsakey']);
+ });
+
+ test('a changed key is refused even when StrictHostKeyChecking is disabled', async () => {
+ // The opt-out means "I accept unknown keys", not "I accept a key that
+ // contradicts one I already trust". OpenSSH 9.9 keeps protecting this
+ // case too: it warns and disables password auth, keyboard-interactive
+ // auth and agent forwarding. We refuse outright, so no credential and
+ // no agent access ever reaches a possible impostor — and the
+ // announcement path is moot because the session never authenticates.
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: FINGERPRINT, addedAt: 1 });
+ await fireAndWait(makeHostKeyRequest({ fingerprint: 'SHA256:impostorkey', strictHostKeyChecking: 'no' }));
+
+ mainService.fireHostKeysAnnouncement({
+ connectionKey: 'ssh:remote.example',
+ host: 'remote.example',
+ port: 22,
+ keys: [{ keyType: 'ssh-ed25519', fingerprint: 'SHA256:attackerkey' }],
+ });
+
+ assert.deepStrictEqual(
+ {
+ // Refused outright, before authentication.
+ connected: mainService.hostKeyResponses,
+ // And the genuine stored key is untouched.
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => k.fingerprint),
+ },
+ {
+ connected: [{ requestId: 'hostkey-1', trusted: false }],
+ stored: [FINGERPRINT],
+ });
+ });
+
+ test('an unverified session cannot poison stored trust via announcements', async () => {
+ // A session accepted under StrictHostKeyChecking=no is unverified: the
+ // key was simply not checked. ssh2 still proves announced keys belong
+ // to whoever we are talking to — but that could be an impostor, so the
+ // announcement must not overwrite the real stored key. Mirrors
+ // OpenSSH, which only accepts additional host keys when the key that
+ // authenticated the host was already trusted.
+ //
+ // Uses an *unknown* key (a different algorithm), since a key that
+ // contradicts the stored one is now refused outright by the test above.
+ hostKeyTrustService.trustHostKey('remote.example', 22, { keyType: 'ssh-ed25519', fingerprint: FINGERPRINT, addedAt: 1 });
+ await fireAndWait(makeHostKeyRequest({ keyType: 'ssh-rsa', fingerprint: 'SHA256:impostorkey', strictHostKeyChecking: 'no' }));
+
+ mainService.fireHostKeysAnnouncement({
+ connectionKey: 'ssh:remote.example',
+ host: 'remote.example',
+ port: 22,
+ keys: [{ keyType: 'ssh-ed25519', fingerprint: 'SHA256:attackerkey' }],
+ });
+
+ assert.deepStrictEqual(
+ {
+ // The unverified session was allowed to connect...
+ connected: mainService.hostKeyResponses,
+ // ...but the genuine stored key is untouched.
+ stored: hostKeyTrustService.getTrustedKeys('remote.example', 22).map(k => k.fingerprint),
+ },
+ {
+ connected: [{ requestId: 'hostkey-1', trusted: true }],
+ stored: [FINGERPRINT],
+ });
+ });
+
+ test('ignores announcements for hosts that were never trusted', async () => {
+ // Otherwise an announcement would become a way to establish trust
+ // without any verification at all.
+ mainService.fireHostKeysAnnouncement({
+ connectionKey: 'ssh:remote.example',
+ host: 'remote.example',
+ port: 22,
+ keys: [{ keyType: 'ssh-ed25519', fingerprint: 'SHA256:rotated' }],
+ });
+
+ assert.strictEqual(hostKeyTrustService.getTrustedKeys('remote.example', 22).length, 0);
+ });
+});
diff --git a/src/vs/platform/agentHost/test/node/agentHostService.test.ts b/src/vs/platform/agentHost/test/node/agentHostService.test.ts
new file mode 100644
index 00000000000..30c9cd654bd
--- /dev/null
+++ b/src/vs/platform/agentHost/test/node/agentHostService.test.ts
@@ -0,0 +1,169 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import { Emitter, Event } from '../../../../base/common/event.js';
+import { DisposableStore } from '../../../../base/common/lifecycle.js';
+import { IChannel, IChannelClient } from '../../../../base/parts/ipc/common/ipc.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
+import { NullLogService, NullLoggerService } from '../../../log/common/log.js';
+import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js';
+import { IAgentHostConnection, IAgentHostStarter } from '../../common/agent.js';
+import { AgentHostProcessManager } from '../../node/agentHostService.js';
+
+class TestChannel implements IChannel {
+ call(_command: string, _arg?: unknown): Promise {
+ return Promise.resolve([] as T);
+ }
+
+ listen(_event: string, _arg?: unknown): Event {
+ return Event.None;
+ }
+}
+
+class TestAgentHostStarter implements IAgentHostStarter {
+ private readonly _onRequestConnection = new Emitter();
+ readonly onRequestConnection = this._onRequestConnection.event;
+
+ private readonly _exitEmitters: Emitter<{ code: number; signal: string }>[] = [];
+ private readonly _channel = new TestChannel();
+ readonly connectionStores: DisposableStore[] = [];
+ startCount = 0;
+
+ async start(): Promise {
+ this.startCount++;
+ const exitEmitter = new Emitter<{ code: number; signal: string }>();
+ this._exitEmitters.push(exitEmitter);
+ const store = new DisposableStore();
+ store.add(exitEmitter);
+ this.connectionStores.push(store);
+ const client: IChannelClient = {
+ getChannel: (): T => this._channel as T,
+ };
+ return {
+ client,
+ store,
+ onDidProcessExit: exitEmitter.event,
+ };
+ }
+
+ requestConnection(): void {
+ this._onRequestConnection.fire();
+ }
+
+ fireProcessExit(code: number): void {
+ this._exitEmitters.at(-1)?.fire({ code, signal: 'unknown' });
+ }
+
+ dispose(): void {
+ this._onRequestConnection.dispose();
+ for (const store of this.connectionStores) {
+ store.dispose();
+ }
+ }
+}
+
+class TestTelemetryService extends NullTelemetryServiceShape {
+ readonly errorEvents: { eventName: string; data: unknown }[] = [];
+
+ override publicLogError2(eventName?: string, data?: unknown): void {
+ if (eventName) {
+ this.errorEvents.push({ eventName, data });
+ }
+ }
+}
+
+suite('AgentHostProcessManager', () => {
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ async function createManager(platform: NodeJS.Platform = 'linux'): Promise<{
+ starter: TestAgentHostStarter;
+ telemetryService: TestTelemetryService;
+ }> {
+ const starter = new TestAgentHostStarter();
+ const telemetryService = new TestTelemetryService();
+ disposables.add(new AgentHostProcessManager(
+ starter,
+ platform,
+ new NullLogService(),
+ disposables.add(new NullLoggerService()),
+ telemetryService,
+ ));
+ starter.requestConnection();
+ await Promise.resolve();
+ return { starter, telemetryService };
+ }
+
+ for (const [name, code] of [
+ ['STATUS_DLL_INIT_FAILED_LOGOFF', 0xC000026B],
+ ['DBG_TERMINATE_PROCESS', 0x40010004],
+ ] as const) {
+ test(`does not restart or report ${name} during Windows shutdown`, async () => {
+ const { starter, telemetryService } = await createManager('win32');
+
+ starter.fireProcessExit(code);
+ await Promise.resolve();
+
+ assert.deepStrictEqual({
+ startCount: starter.startCount,
+ connectionDisposed: starter.connectionStores[0].isDisposed,
+ errorEvents: telemetryService.errorEvents,
+ }, {
+ startCount: 1,
+ connectionDisposed: true,
+ errorEvents: [],
+ });
+ });
+ }
+
+ test('restarts and reports the same exit code on non-Windows platforms', async () => {
+ const { starter, telemetryService } = await createManager('linux');
+
+ starter.fireProcessExit(0xC000026B);
+ await Promise.resolve();
+
+ assert.deepStrictEqual({
+ startCount: starter.startCount,
+ errorEvents: telemetryService.errorEvents,
+ }, {
+ startCount: 2,
+ errorEvents: [{
+ eventName: 'agentHost.processError',
+ data: {
+ hostLaunchKind: 'vscode_main_process',
+ kind: 'unexpectedExit',
+ code: 0xC000026B,
+ restartCount: 0,
+ willRestart: true,
+ isError: true,
+ },
+ }],
+ });
+ });
+
+ test('stops after the configured number of restarts', async () => {
+ const { starter, telemetryService } = await createManager();
+
+ for (let restartCount = 0; restartCount <= 5; restartCount++) {
+ starter.fireProcessExit(17);
+ await Promise.resolve();
+ }
+
+ assert.deepStrictEqual({
+ startCount: starter.startCount,
+ errorEvents: telemetryService.errorEvents,
+ }, {
+ startCount: 6,
+ errorEvents: [
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 0, willRestart: true, isError: true } },
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 1, willRestart: true, isError: true } },
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 2, willRestart: true, isError: true } },
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 3, willRestart: true, isError: true } },
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 4, willRestart: true, isError: true } },
+ { eventName: 'agentHost.processError', data: { hostLaunchKind: 'vscode_main_process', kind: 'unexpectedExit', code: 17, restartCount: 5, willRestart: false, isError: true } },
+ ],
+ });
+ });
+});
diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
index ccc2c882bad..8f984d8cb42 100644
--- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
+++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
@@ -34,6 +34,7 @@ import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTelemetryLevelConf
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js';
import { AgentHostClientType } from '../../common/agentHostClientInfo.js';
+import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js';
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js';
import { IAgentHostChangesetService, StaticChangesetKind } from '../../common/agentHostChangesetService.js';
import { IAgentHostGitService } from '../../common/agentHostGitService.js';
@@ -238,6 +239,7 @@ suite('AgentSideEffects', () => {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
+ hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
onTurnComplete: () => { },
}, undefined, disposables.add(new AgentHostTelemetryService(telemetryService)));
@@ -321,13 +323,22 @@ suite('AgentSideEffects', () => {
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] },
- }, 'client-agents', AgentHostClientType.AgentsWindow);
+ }, 'client-agents', {
+ clientType: AgentHostClientType.AgentsWindow,
+ connectionKind: AgentHostClientConnectionKind.DevTunnel,
+ transportKind: AgentHostTransportKind.WebSocket,
+ hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
+ });
assert.deepStrictEqual(telemetryService.events, [{
eventName: 'agentHost.userMessageSent',
data: {
provider: 'mock',
+ hostLaunchKind: 'vscode_main_process',
+ initiatorClientId: 'client-agents',
initiatorClientType: 'agents_window',
+ initiatorConnectionKind: 'dev_tunnel',
+ initiatorTransportKind: 'websocket',
agentSessionId: 'session-1',
source: 'direct',
isSubagentSession: false,
@@ -2211,7 +2222,11 @@ suite('AgentSideEffects', () => {
eventName: 'agentHost.userMessageSent',
data: {
provider: 'mock',
+ hostLaunchKind: 'vscode_main_process',
+ initiatorClientId: undefined,
initiatorClientType: 'unknown',
+ initiatorConnectionKind: 'unknown',
+ initiatorTransportKind: 'unknown',
agentSessionId: 'session-1',
source: 'queued',
isSubagentSession: false,
diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts
index a7f45dbdc38..a1270ff8ccf 100644
--- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts
+++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts
@@ -60,6 +60,7 @@ import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpo
import { createTestGitHubEndpointService } from './testGitHubEndpointService.js';
import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js';
import { IAgentHostGitService } from '../../common/agentHostGitService.js';
+import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js';
import { ClaudeAgent } from '../../node/claude/claudeAgent.js';
import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js';
import { IAgentPluginManager } from '../../common/agentPluginManager.js';
@@ -674,6 +675,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () {
[IAgentHostStateManager, stateManager],
[IAgentHostGitHubEndpointService, createTestGitHubEndpointService()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
...claudeFileEnvServices(disposables),
);
const instantiationService = disposables.add(new InstantiationService(services));
@@ -807,6 +809,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () {
[IAgentHostStateManager, stateManager],
[IAgentHostGitHubEndpointService, createTestGitHubEndpointService()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
...claudeFileEnvServices(disposables),
);
const instantiationService = disposables.add(new InstantiationService(services));
@@ -884,6 +887,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () {
[IAgentHostStateManager, stateManager],
[IAgentHostGitHubEndpointService, createTestGitHubEndpointService()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
...claudeFileEnvServices(disposables),
);
const instantiationService = disposables.add(new InstantiationService(services));
diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts
index 4ef1de4dde8..13ca1d446fa 100644
--- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts
+++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts
@@ -55,6 +55,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js';
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
import { ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputRequestPurpose, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js';
import { IAgentHostGitService } from '../../common/agentHostGitService.js';
+import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js';
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js';
@@ -72,7 +73,7 @@ import { resolvePromptToContentBlocks } from '../../node/claude/claudePromptReso
import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js';
import { AgentService } from '../../node/agentService.js';
import { injectSideChatContext } from '../../node/agentPeerChats.js';
-import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
+import { createNoopGitService, createNullSessionDataService, createSessionDataService, RecordingCheckpointService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
// #region Test fakes
@@ -818,7 +819,7 @@ class CapturingLogService extends NullLogService {
function createTestContext(
disposables: Pick,
- overrides?: { logService?: ILogService; database?: TestSessionDatabase; rootConfig?: Record; userHome?: URI; gitHubEndpointService?: IAgentHostGitHubEndpointService },
+ overrides?: { logService?: ILogService; database?: TestSessionDatabase; rootConfig?: Record; userHome?: URI; gitHubEndpointService?: IAgentHostGitHubEndpointService; checkpointService?: IAgentHostCheckpointService },
): ITestContext {
const proxy = new FakeClaudeProxyService();
const api = new FakeCopilotApiService();
@@ -849,6 +850,7 @@ function createTestContext(
[IClaudeAgentSdkService, sdk],
[IAgentPluginManager, new FakeAgentPluginManager()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, overrides?.checkpointService ?? NULL_CHECKPOINT_SERVICE],
[IAgentConfigurationService, configService],
[IAgentHostStateManager, stateManager],
[IAgentHostOTelService, otelService],
@@ -922,6 +924,7 @@ function createTestAgentStateServices(disposables: Pick)
[IAgentConfigurationService, disposables.add(new AgentConfigurationService(stateManager, logService))],
[IAgentHostStateManager, stateManager],
[IAgentHostOTelService, new RecordingOTelService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
];
}
@@ -1902,6 +1905,30 @@ suite('ClaudeAgent', () => {
);
});
+ test('captures the baseline checkpoint on fresh materialize but not on resume (parity with Copilot)', async () => {
+ const checkpointService = new RecordingCheckpointService();
+ const { agent, sdk } = createTestContext(disposables, { checkpointService });
+ await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok');
+
+ const workDir = URI.file('/work-baseline');
+
+ // Fresh materialize captures the baseline for the resolved directories.
+ const created = await agent.createSession({ workingDirectories: [workDir] });
+ const sessionId = AgentSession.id(created.session);
+ sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)];
+ await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', [workDir], undefined, 'turn-1');
+
+ // Cross-window resume (dispose + second send) must NOT capture a late baseline.
+ await agent.disposeSession(created.session);
+ sdk.sessionList = [{ sessionId, cwd: workDir.fsPath, summary: '', lastModified: Date.now() }];
+ sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)];
+ await agent.chats.sendMessage(defaultChatUri(created.session), 'turn 2', [workDir], undefined, 'turn-2');
+
+ assert.deepStrictEqual(checkpointService.baselineCalls, [
+ { session: created.session.toString(), workingDirectories: [workDir.toString()] },
+ ]);
+ });
+
test('createSession honors config.session when the workbench pre-mints the URI', async () => {
// Workbench eagerly mints the session URI client-side (PR #313841
// folder-pick path) and round-trips it through createSession so
@@ -3651,6 +3678,7 @@ suite('ClaudeAgent', () => {
[IClaudeAgentSdkService, sdk],
[IAgentPluginManager, new FakeAgentPluginManager()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
[IAgentConfigurationService, configService],
[IAgentHostStateManager, stateManager],
[IAgentHostOTelService, new RecordingOTelService()],
@@ -4907,6 +4935,7 @@ suite('ClaudeAgent', () => {
[IClaudeAgentSdkService, sdk],
[IAgentPluginManager, new FakeAgentPluginManager()],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
[IAgentConfigurationService, configService],
[IAgentHostStateManager, stateManager],
[IAgentHostOTelService, new RecordingOTelService()],
@@ -6819,6 +6848,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => {
[IClaudeAgentSdkService, sdk],
[IAgentPluginManager, pluginManager],
[IAgentHostGitService, createNoopGitService()],
+ [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
[IAgentConfigurationService, configService],
[IAgentHostStateManager, stateManager],
[IAgentHostOTelService, otelService],
diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts
index 2a6de1b1de2..5603fdc60de 100644
--- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts
+++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts
@@ -16,6 +16,7 @@ import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEn
import { AgentConfigurationService, IAgentConfigurationService } from '../../../node/agentConfigurationService.js';
import { AgentHostStateManager } from '../../../node/agentHostStateManager.js';
import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js';
+import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js';
import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js';
import { ICodexProxyService } from '../../../node/codex/codexProxyService.js';
import { ICopilotApiService } from '../../../node/shared/copilotApiService.js';
@@ -36,6 +37,7 @@ function createAgent(disposables: Pick, models: () => Pr
instantiationService.stub(IAgentConfigurationService, configurationService);
instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService());
instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined });
+ instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE);
instantiationService.stub(IAgentHostOTelService, { _serviceBrand: undefined, getNativeSdkTelemetryConfig: async () => undefined });
instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService);
instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') });
diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts
index ab81e1a2dde..e67d3673147 100644
--- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts
+++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts
@@ -33,6 +33,7 @@ import { AgentConfigurationService, IAgentConfigurationService } from '../../../
import { AgentHostStateManager } from '../../../node/agentHostStateManager.js';
import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEndpointService.js';
import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js';
+import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js';
import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js';
import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js';
import { CodexAppServerClient, type ICodexAppServerTransport } from '../../../node/codex/codexAppServerClient.js';
@@ -43,7 +44,7 @@ import { AgentHostCodexMultiRootEnabledConfigKey } from '../../../common/agentHo
import { CodexSessionConfigKey } from '../../../common/codexSessionConfigKeys.js';
import type { SandboxPolicy } from '../../../node/codex/protocol/generated/v2/SandboxPolicy.js';
import type { SelectedCapabilityRoot } from '../../../node/codex/protocol/generated/v2/SelectedCapabilityRoot.js';
-import { createSessionDataService, TestSessionDatabase } from '../../common/sessionTestHelpers.js';
+import { createSessionDataService, RecordingCheckpointService, TestSessionDatabase } from '../../common/sessionTestHelpers.js';
interface ITestWireRequest {
readonly id: number;
@@ -131,6 +132,7 @@ interface ICreateAgentOptions {
readonly multiRootEnabled?: boolean;
readonly sessionConfig?: Readonly>;
readonly database?: TestSessionDatabase;
+ readonly checkpointService?: IAgentHostCheckpointService;
}
class TestCodexLogService extends NullLogService {
@@ -189,6 +191,7 @@ async function createAgent(disposables: Pick