mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-18 13:17:05 +01:00
Merge remote-tracking branch 'origin/main' into benibenj/inner-leopard
# Conflicts: # src/vs/sessions/contrib/chat/browser/newChatInput.ts
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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'))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 (`<base>-<platform>-<arch>`) 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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) },
|
||||
|
||||
@@ -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<i32, AnyError> {
|
||||
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());
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ pub async fn update(ctx: CommandContext, args: StandaloneUpdateArgs) -> Result<i
|
||||
"{} is already up to date ({})",
|
||||
PRODUCT_NAME_LONG, current_version.commit
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if args.check {
|
||||
|
||||
@@ -4462,6 +4462,17 @@
|
||||
"advanced"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.autoModeTierOverride": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"default": null,
|
||||
"markdownDescription": "Overrides the routing tier that the `Auto` model requests, ignoring both the tier picked in the model picker and the tier inline chat defaults to. Accepts `eco`, `balanced`, `max`, or `fast`. Used by evals.\n\n**Note**: This is an advanced debugging setting.",
|
||||
"tags": [
|
||||
"advanced"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.anthropic.promptCaching.extendedTtl": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
@@ -5164,6 +5175,15 @@
|
||||
"onExp"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.autoMode.tiers.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "%github.copilot.config.chat.autoMode.tiers.enabled%",
|
||||
"tags": [
|
||||
"advanced",
|
||||
"onExp"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.agent.modelDetails.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
||||
@@ -431,6 +431,7 @@
|
||||
"github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.",
|
||||
"github.copilot.config.cli.autoModel.enabled": "Enable the Auto model option in Copilot CLI, which automatically selects the best model for each request. Requires VS Code reload.",
|
||||
"github.copilot.config.chat.autoMode.v2.enabled": "Use the single-call Auto API to select the best model for each request. When disabled, model selection falls back to the previous two-call flow.",
|
||||
"github.copilot.config.chat.autoMode.tiers.enabled": "Choose a routing tier for the Auto model, biasing model selection toward cost, capability, or speed. When disabled, the service picks the routing profile.",
|
||||
"github.copilot.config.chat.agent.modelDetails.enabled": "Show model details (model name and request multiplier) on agent chat responses when using Copilot CLI or Claude agent in VS Code. Requires VS Code reload to update already loaded sessions.",
|
||||
"github.copilot.config.cli.planCommand.enabled": "Enable the /plan command in Copilot CLI to create implementation plans before coding.",
|
||||
"github.copilot.config.cli.lazyLoadSessionItem.enabled": "Enable lazy loading of session items in Copilot CLI. Requires VS Code reload.",
|
||||
|
||||
+21
-64
@@ -29,7 +29,7 @@ import { disposableTimeout, raceCancellation, raceCancellationError, SequencerBy
|
||||
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
|
||||
import { Emitter, Event } from '../../../../util/vs/base/common/event';
|
||||
import { Lazy } from '../../../../util/vs/base/common/lazy';
|
||||
import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
|
||||
import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
|
||||
import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources';
|
||||
import { URI } from '../../../../util/vs/base/common/uri';
|
||||
import { generateUuid } from '../../../../util/vs/base/common/uuid';
|
||||
@@ -55,8 +55,6 @@ import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from
|
||||
|
||||
|
||||
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
|
||||
const AGENT_HOST_DEFAULT_SESSIONS_PROVIDER_SETTING_ID = 'chat.agentHost.defaultSessionsProvider';
|
||||
const COPILOT_CLI_HIDE_EXTENSION_HOST_EDITOR_SETTING_ID = 'chat.editor.copilotCli.hideExtensionHost';
|
||||
const AGENT_HOST_COPILOT_CLIENT_NAME = 'vscode-agent-host';
|
||||
export const COPILOT_CLI_CHAT_PANEL_SYSTEM_MESSAGE = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.';
|
||||
|
||||
@@ -147,7 +145,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
|
||||
private readonly _sessionTracker: CopilotCLISessionWorkspaceTracker;
|
||||
private readonly _sessionWorkingDirectories = new Map<string, Uri | undefined>();
|
||||
private readonly _onDidChangeSessionsThrottler = this._register(new ThrottledDelayer<void>(500));
|
||||
private readonly _sessionFileMonitor = this._register(new MutableDisposable<IDisposable>());
|
||||
private readonly _cachedSessionItems = new Map<string, ICopilotCLISessionItem>();
|
||||
private readonly _sessionsBeingCreatedViaFork = new Set<string>();
|
||||
private readonly _newSessionIds = new Set<string>();
|
||||
@@ -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<Promise<internal.LocalSessionManager>>(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<boolean>(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<SDKPackage> {
|
||||
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<string>();
|
||||
private triggerOnDidChangeSessionItem(sessionId: string, reason: 'fileSystemChange' | 'statusChange') {
|
||||
private readonly _sessionChangeNotifierByKey = new SequencerByKey<string>();
|
||||
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<void>(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: <sessionDir>/<sessionId>/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);
|
||||
|
||||
+20
-83
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-15
@@ -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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
|
||||
|
||||
+3
-18
@@ -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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
|
||||
|
||||
+42
-1
@@ -98,7 +98,7 @@ class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
override getWorktreeProperties = vi.fn(async (_sessionId: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
|
||||
override setWorktreeProperties = vi.fn(async () => { });
|
||||
override getWorktreeChanges = vi.fn(async () => []);
|
||||
override getWorktreeChanges = vi.fn<IChatSessionWorktreeService['getWorktreeChanges']>(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();
|
||||
|
||||
@@ -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<LanguageModelConfigurationSchema['properties']>[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.
|
||||
|
||||
@@ -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, NonNullable<vscode.LanguageModelConfigurationSchema['properties']>[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<vscode.LanguageModelChatInformation[]> {
|
||||
@@ -329,6 +338,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
|
||||
|
||||
const seenFamilies = new Set<string>();
|
||||
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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
|
||||
@@ -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<boolean>('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<boolean>('chat.autoMode.tiers.enabled', ConfigType.ExperimentBased, false, undefined, undefined, { experimentName: 'copilotchat.autoModeTiersEnabled' });
|
||||
export const CLIModelDetailsEnabled = defineSetting<boolean>('chat.agent.modelDetails.enabled', ConfigType.Simple, true);
|
||||
export const CLIPlanCommandEnabled = defineSetting<boolean>('chat.cli.planCommand.enabled', ConfigType.Simple, true);
|
||||
export const CLIChatLazyLoadSessionItem = defineSetting<boolean>('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<string | null>('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<string | null>('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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChatLocation> = 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<AutoModePickerMetadata | undefined>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<void> | undefined;
|
||||
/** Session used only to read discounts for the picker on the legacy flow. */
|
||||
private readonly _pickerTokenBank = this._register(new MutableDisposable<AutoModeTokenBank>());
|
||||
private readonly _onDidChangeAutoModeTierSupport = this._register(new Emitter<void>());
|
||||
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<Record<string, number>>(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<string, number> | 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<string, number> | 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<AutoModePickerMetadata | undefined> {
|
||||
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<IChatEndpoint | undefined> {
|
||||
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;
|
||||
|
||||
@@ -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<BaseConfig<unknown>, unknown> = new Map()): void {
|
||||
configurationService = new InMemoryConfigurationService(
|
||||
new DefaultsOnlyConfigurationService(),
|
||||
new Map([[ConfigKey.Advanced.AutoModeV2Enabled, true]]),
|
||||
new Map<BaseConfig<unknown>, 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<BaseConfig<unknown>, unknown>([[ConfigKey.Advanced.AutoModeTiersEnabled, true]]));
|
||||
}
|
||||
|
||||
function enableAutoV2WithTierOverride(override: string): void {
|
||||
enableAutoV2(new Map<BaseConfig<unknown>, 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<ChatRequest> = {
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<BaseConfig<unknown>, 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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({
|
||||
|
||||
@@ -162,14 +162,14 @@ export class SurveyService implements ISurveyService {
|
||||
|
||||
private async promptSurvey(surveyType: 'churn' | 'usage'): Promise<void> {
|
||||
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." },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 <sample-id>.scoredEdits.w.json files beside the output JSONL`,
|
||||
` Requires --sample-task=xtab`,
|
||||
` --sample-task Which target to generate (default: xtab)`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<typeof deserializeStringEdit>): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<IProcessedRow, Error> {
|
||||
export function processContinuousRecord(record: IContinuousRecord, pivotTime: number, maxOracleEdits?: number): Result<IProcessedRow, Error> {
|
||||
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<IProcessedRow, Error> {
|
||||
function _processContinuousRecord(record: IContinuousRecord, pivotTime: number, maxOracleEdits: number | undefined): Result<IProcessedRow, Error> {
|
||||
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<Error>[];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
));
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<IProcessedRow, Error> {
|
||||
export function processRow(row: IInputRow, maxOracleEdits?: number): Result<IProcessedRow, Error> {
|
||||
try {
|
||||
return _processRow(row);
|
||||
return _processRow(row, maxOracleEdits);
|
||||
} catch (e: unknown) {
|
||||
return Result.error(ErrorUtils.fromUnknown(e));
|
||||
}
|
||||
}
|
||||
|
||||
function _processRow(row: IInputRow): Result<IProcessedRow, Error> {
|
||||
function _processRow(row: IInputRow, maxOracleEdits: number | undefined): Result<IProcessedRow, Error> {
|
||||
const proposedEdits = coalesce([parseSuggestedEdit(row.postProcessingOutcome.suggestedEdit)]);
|
||||
const isAccepted = row.suggestionStatus === 'accepted';
|
||||
|
||||
@@ -135,6 +135,7 @@ function _processRow(row: IInputRow): Result<IProcessedRow, Error> {
|
||||
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<IProcessedRow, Error> {
|
||||
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<Error>[];
|
||||
} {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ export function processWorkspaceRecordingSample(
|
||||
pivotEntryIndex: sample.pivotEntryIndex,
|
||||
proposedEdits: [],
|
||||
isAccepted: false,
|
||||
oracleEdits: descriptor.oracleEdits,
|
||||
workspaceRecording: sample.provenance,
|
||||
});
|
||||
if (result.isError()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IWorksp
|
||||
export function selectWorkspaceRecordingSamples(
|
||||
recording: IWorkspaceRecording,
|
||||
maxSamples: number,
|
||||
maxOracleEdits = DEFAULT_NES_DATAGEN_ORACLE_EDIT_LIMIT,
|
||||
): IWorkspaceRecordingSampleDescriptor[] {
|
||||
if (!Number.isInteger(maxOracleEdits) || maxOracleEdits <= 0) {
|
||||
throw new Error(`Workspace recording oracle edit limit must be a positive integer, but got: ${maxOracleEdits}`);
|
||||
}
|
||||
|
||||
const classifications = createEditClassifications(recording);
|
||||
const deliberateCursorOperations = findDeliberateCursorOperations(recording);
|
||||
const candidates: IWorkspaceRecordingSampleDescriptor[] = [];
|
||||
@@ -145,7 +149,11 @@ export function selectWorkspaceRecordingSamples(
|
||||
}
|
||||
|
||||
const oracle = collectOracle(recording, operation, classifications, deliberateCursorOperations);
|
||||
if (oracle.operationIndices.length === 0) {
|
||||
if (oracle.operationIndices.length === 0 || oracle.stopReason === 'end-of-recording' || oracle.stopReason === 'touching-boundary') {
|
||||
continue;
|
||||
}
|
||||
const oracleEdits = composeOracleEdits(recording, oracle.operationIndices, maxOracleEdits);
|
||||
if (oracleEdits.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -153,6 +161,7 @@ export function selectWorkspaceRecordingSamples(
|
||||
pivotOperationIndex: operation.operationIdx,
|
||||
pivotKind,
|
||||
oracleOperationIndices: oracle.operationIndices,
|
||||
oracleEdits,
|
||||
cursorBoundaryOperationIndex: oracle.cursorBoundaryOperationIndex,
|
||||
oracleStopReason: oracle.stopReason,
|
||||
});
|
||||
@@ -286,7 +295,7 @@ export function materializeWorkspaceRecordingSample(
|
||||
provenance: {
|
||||
sourceFormat: 'workspace-recording',
|
||||
recordingRevision: recording.revision,
|
||||
policyVersion: 1,
|
||||
policyVersion: 2,
|
||||
pivotKind: descriptor.pivotKind,
|
||||
pivotOperationIndex: descriptor.pivotOperationIndex,
|
||||
oracleOperationCount: descriptor.oracleOperationIndices.length,
|
||||
@@ -532,10 +541,17 @@ function classifyMetadata(metadata: Record<string, unknown> | 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<string, EditClassification> {
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 2L1 3V13L2 14H14L15 13V3L14 2H2ZM2 13V3H14V13H2ZM12 5H4V6H12V5ZM3 4V7H13V4H3ZM7 9H3V8H7V9ZM3 12H7V11H3V12ZM12 9H10V11H12V9ZM9 8V12H13V8H9Z" fill="#C5C5C5"/>
|
||||
<path d="M12.5 1H3.5C2.122 1 1 2.122 1 3.5V12.5C1 13.878 2.122 15 3.5 15H12.5C13.878 15 15 13.878 15 12.5V3.5C15 2.122 13.878 1 12.5 1ZM14 12.5C14 13.327 13.327 14 12.5 14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H12.5C13.327 2 14 2.673 14 3.5V12.5ZM11 4H5C4.448 4 4 4.448 4 5V7C4 7.552 4.448 8 5 8H11C11.552 8 12 7.552 12 7V5C12 4.448 11.552 4 11 4ZM11 7H5V5H11V7ZM11 9H10C9.448 9 9 9.448 9 10V11C9 11.552 9.448 12 10 12H11C11.552 12 12 11.552 12 11V10C12 9.448 11.552 9 11 9ZM11 11H10V10H11V11ZM8 9.5C8 9.776 7.776 10 7.5 10H4.5C4.224 10 4 9.776 4 9.5C4 9.224 4.224 9 4.5 9H7.5C7.776 9 8 9.224 8 9.5ZM8 11.5C8 11.776 7.776 12 7.5 12H4.5C4.224 12 4 11.776 4 11.5C4 11.224 4.224 11 4.5 11H7.5C7.776 11 8 11.224 8 11.5Z" fill="#C5C5C5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 853 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 2L1 3V13L2 14H14L15 13V3L14 2H2ZM2 13V3H14V13H2ZM12 5H4V6H12V5ZM3 4V7H13V4H3ZM7 9H3V8H7V9ZM3 12H7V11H3V12ZM12 9H10V11H12V9ZM9 8V12H13V8H9Z" fill="#424242"/>
|
||||
<path d="M12.5 1H3.5C2.122 1 1 2.122 1 3.5V12.5C1 13.878 2.122 15 3.5 15H12.5C13.878 15 15 13.878 15 12.5V3.5C15 2.122 13.878 1 12.5 1ZM14 12.5C14 13.327 13.327 14 12.5 14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H12.5C13.327 2 14 2.673 14 3.5V12.5ZM11 4H5C4.448 4 4 4.448 4 5V7C4 7.552 4.448 8 5 8H11C11.552 8 12 7.552 12 7V5C12 4.448 11.552 4 11 4ZM11 7H5V5H11V7ZM11 9H10C9.448 9 9 9.448 9 10V11C9 11.552 9.448 12 10 12H11C11.552 12 12 11.552 12 11V10C12 9.448 11.552 9 11 9ZM11 11H10V10H11V11ZM8 9.5C8 9.776 7.776 10 7.5 10H4.5C4.224 10 4 9.776 4 9.5C4 9.224 4.224 9 4.5 9H7.5C7.776 9 8 9.224 8 9.5ZM8 11.5C8 11.776 7.776 12 7.5 12H4.5C4.224 12 4 11.776 4 11.5C4 11.224 4.224 11 4.5 11H7.5C7.776 11 8 11.224 8 11.5Z" fill="#424242"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 853 B |
@@ -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;
|
||||
|
||||
|
||||
+51
-24
@@ -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<string, number> | 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 = <T>(listeners: ListenerOrListeners<T>, fn: (c: ListenerC
|
||||
export class Emitter<T> {
|
||||
|
||||
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<T>;
|
||||
@@ -1195,13 +1207,22 @@ export class Emitter<T> {
|
||||
|
||||
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<T> {
|
||||
*/
|
||||
get event(): Event<T> {
|
||||
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<T> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<HTMLElement>('.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<HTMLElement>('.action-item')!;
|
||||
const submenuItem = submenuAction.querySelector<HTMLElement>('.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()));
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>({
|
||||
leakWarningName: 'captured',
|
||||
onListenerError: error => errors.push(error),
|
||||
}));
|
||||
restoreThreshold.dispose();
|
||||
restoreThreshold = undefined;
|
||||
|
||||
const unmonitoredEmitter = ds.add(new Emitter<void>({
|
||||
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() {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, boolean> | undefined = undefined;
|
||||
|
||||
@@ -90,6 +90,7 @@ export class InlineCompletionsSource extends Disposable {
|
||||
private readonly _versionId: IObservableWithChange<number | null, IModelContentChangedEvent | undefined>,
|
||||
private readonly _debounceValue: IFeatureDebounceInformation,
|
||||
private readonly _cursorPosition: IObservable<Position>,
|
||||
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 {
|
||||
|
||||
@@ -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<void>();
|
||||
const providerResponse = new DeferredPromise<InlineCompletions>();
|
||||
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('',
|
||||
|
||||
@@ -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<T>(
|
||||
@@ -320,7 +322,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> } | Record<string, never> {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, ISSHTrustedHostKey[]> {
|
||||
const hosts = new Map<string, ISSHTrustedHostKey[]>();
|
||||
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<string, unknown>)) {
|
||||
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<string>());
|
||||
readonly onDidChangeTrustedHosts: Event<string> = 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<string, ISSHTrustedHostKey[]> {
|
||||
return parseTrustedHostKeys(this._storageService.get(SSH_HOST_KEY_TRUST_STORAGE_KEY, StorageScope.APPLICATION));
|
||||
}
|
||||
|
||||
private _write(hosts: Map<string, ISSHTrustedHostKey[]>): 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ProtocolMessage>());
|
||||
readonly onMessage = this._onMessage.event;
|
||||
|
||||
@@ -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<ProtocolMessage>());
|
||||
readonly onMessage = this._onMessage.event;
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.' };
|
||||
|
||||
@@ -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<string, unknown> | undefined {
|
||||
return connectionKind === undefined || connectionKind === AgentHostClientConnectionKind.Unknown
|
||||
? undefined
|
||||
: { [CLIENT_CONNECTION_KIND_META_KEY]: connectionKind };
|
||||
}
|
||||
|
||||
export function readClientConnectionKind(meta: Record<string, unknown> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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<ISSHHostKeyTrustService>('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<string>;
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
@@ -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<ISSHKeyboardInteractiveRequest>;
|
||||
|
||||
@@ -413,6 +527,36 @@ export interface ISSHRemoteAgentHostMainService {
|
||||
*/
|
||||
respondEndpointSelection(requestId: string, selection: ISSHEndpointSelection | undefined): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<ISSHHostKeyVerificationRequest>;
|
||||
|
||||
/**
|
||||
* 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<string /* requestId */>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<ISSHHostKeysAnnouncement>;
|
||||
|
||||
/**
|
||||
* Bootstrap a remote agent host over SSH. Returns serializable
|
||||
* connection info for the renderer to register.
|
||||
|
||||
@@ -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<ProtocolMessage>;
|
||||
|
||||
|
||||
@@ -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<ITunnelConnectResult>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ISSHRelayClientFactory>('sshRelayClientFactory');
|
||||
|
||||
@@ -99,6 +123,14 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
|
||||
*/
|
||||
private readonly _lastConnectedServerTypeByAddress = new Map<string, AgentHostServerType>();
|
||||
|
||||
/**
|
||||
* 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<string, { keyType: string; fingerprint: string }>();
|
||||
|
||||
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 {
|
||||
void handled;
|
||||
}
|
||||
|
||||
private async _handleHostKeyVerificationRequest(request: ISSHHostKeyVerificationRequest): Promise<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
||||
@@ -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<ProtocolMessage>());
|
||||
readonly onMessage = this._onMessage.event;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, number>();
|
||||
private readonly _activeTransports = new Map<string, Set<object>>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
};
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
{ 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<void> {
|
||||
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<void> {
|
||||
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));
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IAgentHostUserMessageSentEvent, IAgentHostUserMessageSentClassification>('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<IAgentHostClientConnectionEvent, IAgentHostClientConnectionClassification>('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
|
||||
|
||||
@@ -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<string, Promise<void>>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1640,27 +1640,32 @@ export class CopilotAgentSession extends Disposable {
|
||||
}
|
||||
|
||||
private _toToolSearchResult(clientResult: ToolResultObject, availableTools: readonly CurrentToolMetadata[] | undefined): ToolResultObject {
|
||||
const deferred = new Set<string>();
|
||||
const deferred = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TContext> extends Disposable implements I
|
||||
}
|
||||
|
||||
class MessagePortProtocolTransport extends Disposable implements IProtocolTransport {
|
||||
readonly transportKind = AgentHostTransportKind.MessagePort;
|
||||
|
||||
private readonly _onFrame = this._register(new Emitter<string>());
|
||||
readonly onFrame = this._onFrame.event;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, ChannelSubscription>;
|
||||
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<string, IClientRecord>();
|
||||
private readonly _replayBuffer: ActionEnvelope[] = [];
|
||||
private readonly _telemetryReporter: AgentHostTelemetryReporter;
|
||||
private readonly _connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker;
|
||||
|
||||
private readonly _onDidChangeConnectionCount = this._register(new Emitter<number>());
|
||||
|
||||
@@ -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<string, unknown> | 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 {
|
||||
|
||||
@@ -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|<salt>|<hash>` 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', '<salt>', '<hash>'].
|
||||
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|<salt>|<hash>`) 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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* 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<string>());
|
||||
readonly onDidCancelEndpointSelection: Event<string> = this._onDidCancelEndpointSelection.event;
|
||||
|
||||
private readonly _onDidRequestHostKeyVerification = this._register(new Emitter<ISSHHostKeyVerificationRequest>());
|
||||
readonly onDidRequestHostKeyVerification: Event<ISSHHostKeyVerificationRequest> = this._onDidRequestHostKeyVerification.event;
|
||||
|
||||
private readonly _onDidCancelHostKeyVerification = this._register(new Emitter<string>());
|
||||
readonly onDidCancelHostKeyVerification: Event<string> = this._onDidCancelHostKeyVerification.event;
|
||||
|
||||
private readonly _onDidAnnounceHostKeys = this._register(new Emitter<ISSHHostKeysAnnouncement>());
|
||||
readonly onDidAnnounceHostKeys: Event<ISSHHostKeysAnnouncement> = 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<string, (selection: ISSHEndpointSelection | undefined) => 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<string, { verify: (trusted: boolean) => void; onUserDenied?: () => void }>();
|
||||
private _hostKeyRequestCounter = 0;
|
||||
|
||||
private readonly _connections = this._register(new DisposableMap<string, SSHConnection>());
|
||||
|
||||
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<SSHClient> {
|
||||
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<string>();
|
||||
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 = <T>(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<string>();
|
||||
// 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<SSHClient>((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<SSHClient> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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{,-<quality>}/<archive>`), 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 `~/<serverDataFolderName>/<archive>` 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 `~/<serverDataFolderName>/<archive>`. 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;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user