Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions extensions/copilot/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7304,7 +7304,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.1",
"undici": "^7.24.1",
"vscode-tas-client": "^0.1.84",
"vscode-tas-client": "^0.3.0",
"web-tree-sitter": "^0.23.0"
},
"overrides": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export interface Endpoints {
'origin-tracker'?: string;
proxy?: string;
telemetry?: string;
exp?: string;
}

//#endregion
Expand Down Expand Up @@ -431,6 +432,7 @@ const tokenEnvelopeValidator = vObj({
'origin-tracker': vString(),
proxy: vString(),
telemetry: vString(),
exp: vString(),
}),
enterprise_list: vNullable(vArray(vNumber())),
limited_user_quotas: vNullable(vObj({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ export class BaseTelemetryService implements ITelemetryService {
"errortype": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth"}
}
*/
/* __GDPR__
"assignments-validation" : {
"FeatureVariableCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
"AssignedVariantCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
"DataVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
"AssignmentContext": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
/* __GDPR__
"call-assignments-error" : {
"ErrorType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth"}
}
*/
if (name === 'abexp.assignmentcontext') {
this._setOriginalExpAssignments(value);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { IExperimentationService as ITASExperimentationService } from 'vsco
import { equals } from '../../../util/vs/base/common/arrays';
import { IntervalTimer } from '../../../util/vs/base/common/async';
import { Emitter } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { Disposable, MutableDisposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore';
import { IConfigurationService } from '../../configuration/common/configurationService';
import { IVSCodeExtensionContext } from '../../extContext/common/extensionContext';
Expand Down Expand Up @@ -124,9 +124,15 @@ export class BaseExperimentationService extends Disposable implements IExperimen
private readonly _refreshTimer = this._register(new IntervalTimer());
private readonly _previouslyReadTreatments = new Map<string, boolean | string | number | undefined>();

protected readonly _delegate: ITASExperimentationService;
protected _delegate: ITASExperimentationService;
protected readonly _userInfoStore: UserInfoStore;

/** Disposes the current delegate (stopping its polling); auto-disposes the previous one on replacement. */
private readonly _delegateDisposable = this._register(new MutableDisposable());

private readonly _delegateFn: TASClientDelegateFn;
private readonly _globalState: vscode.Memento;

protected _onDidTreatmentsChange = this._register(new Emitter<TreatmentsChangeEvent>());
readonly onDidTreatmentsChange = this._onDidTreatmentsChange.event;

Expand Down Expand Up @@ -155,10 +161,26 @@ export class BaseExperimentationService extends Disposable implements IExperimen
this._signalTreatmentsChangeEvent();
}, 60 * 60 * 1000);

this._delegate = delegateFn(context.globalState, this._userInfoStore);
this._delegate.initialFetch.then(() => {
this._delegateFn = delegateFn;
this._globalState = context.globalState;
this._delegate = this._createDelegate();
Comment thread
vijayupadya marked this conversation as resolved.
}

private _createDelegate(): ITASExperimentationService {
const delegate = this._delegateFn(this._globalState, this._userInfoStore);
this._delegateDisposable.value = toDisposable(() => (delegate as unknown as { dispose?(): void }).dispose?.());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: IExperimentationService in tas-client@0.4.2 already requires dispose(), and vscode-tas-client@0.3.0 returns that interface, so this unknown cast and optional call hide the actual lifecycle contract. Please call delegate.dispose() directly (and do the same in the core helper); this will also make future API mismatches fail at compile time.

delegate.initialFetch.then(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: When an endpoint change recreates the delegate, completion of the replacement's initialFetch only logs. Previously read treatment values are never compared or announced, so experiment-backed configuration can remain stale until another trigger, potentially the hourly refresh. Please signal treatment changes after the current replacement delegate finishes its initial fetch, with a generation/current-delegate guard for superseded fetches.

this._logService.trace(`[BaseExperimentationService] Initial fetch completed`);
});
return delegate;
}

/**
* Creates a fresh delegate, disposing the previous one (stopping its polling). Used
* when inputs captured at delegate-creation time (e.g. the assignments endpoint) change.
*/
protected recreateDelegate(): void {
this._delegate = this._createDelegate();
}

private _signalTreatmentsChangeEvent = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class MockTASExperimentationService implements ITASExperimentationService {
this.refreshCallCount = 0;
this.treatmentRequests = [];
}

dispose(): void { }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: The production change adds endpoint-driven delegate replacement, disposal, and asynchronous initial-fetch behavior, but the only test update is this no-op method. Please add focused tests for late endpoint arrival/change, disposal of the old delegate, ignoring superseded fetch completion, and notifying treatment changes after the replacement fetch.

}

describe('ExP Service Tests', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import path from 'path';
import * as vscode from 'vscode';
import { getExperimentationService, IExperimentationFilterProvider, TargetPopulation } from 'vscode-tas-client';
import { getExperimentationServiceFromConfig, IExperimentationFilterProvider, TargetPopulation } from 'vscode-tas-client';
import { platform, PlatformToString } from '../../../util/vs/base/common/platform';
import { isObject } from '../../../util/vs/base/common/types';
import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore';
Expand All @@ -31,6 +31,34 @@ function trimVersionSuffix(version: string): string {
return version.split('-')[0];
}

/**
* Formats an ISO date into the `yyyymmddHH` form the experimentation backend expects
* (10 digits, fits within int32). Returns an empty string when unavailable.
*/
function formatReleaseDate(iso: string): string {
if (!iso) {
return '';
}
const match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})/.exec(iso);
if (!match) {
return '';
}
return match.slice(1, 5).join('');
}

/**
* Reads and formats the product release date from `product.json`, or `undefined` on failure.
*/
function readReleaseDate(logService: ILogService, tag: string): string | undefined {
try {
const product = require(path.join(vscode.env.appRoot, 'product.json'));
return formatReleaseDate(product.date ?? '');
} catch (error) {
logService.warn(`${tag} Failed to read product.json for release date: ${error}`);
return undefined;
}
}

const CopilotRelatedPluginVersionPrefix = 'X-Copilot-RelatedPluginVersion-';

export enum RelatedExtensionsFilter {
Expand Down Expand Up @@ -159,28 +187,7 @@ class PlatformAndReleaseDateFilterProvider implements IExperimentationFilterProv
constructor(
private _logService: ILogService
) {
this._releaseDate = this._initReleaseDate();
}

private _initReleaseDate(): string | undefined {
try {
const product = require(path.join(vscode.env.appRoot, 'product.json'));
return this._formatReleaseDate(product.date ?? '');
} catch (error) {
this._logService.warn(`[PlatformAndReleaseDateFilterProvider]::_initReleaseDate Failed to read product.json for release date: ${error}`);
return undefined;
}
}

private _formatReleaseDate(iso: string): string {
if (!iso) {
return '';
}
const match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})/.exec(iso);
if (!match) {
return '';
}
return match.slice(1, 5).join('');
this._releaseDate = readReleaseDate(_logService, '[PlatformAndReleaseDateFilterProvider]::readReleaseDate');
}

getFilters(): Map<string, string> {
Expand Down Expand Up @@ -215,6 +222,46 @@ class WindowKindFilterProvider implements IExperimentationFilterProvider {
}
}

/**
* Emits the Copilot-side filters for the new TAS assignments API (POST /api/v1/assignments)
* using the new userParam key names. Reads the Copilot token fresh on each call so refreshed
* assignments pick up account changes. The generic `vscode_core_*` app/build/extension/target
* filters are added automatically by `vscode-tas-client`.
*/
class CopilotAssignmentsFilterProvider implements IExperimentationFilterProvider {
private readonly _releaseDate: string | undefined;

constructor(
private readonly _copilotTokenStore: ICopilotTokenStore,
private readonly _logService: ILogService
) {
this._releaseDate = readReleaseDate(_logService, '[CopilotAssignmentsFilterProvider]::readReleaseDate');
}

getFilters(): Map<string, string | undefined> {
const token = this._copilotTokenStore.copilotToken;
const internalOrg = token
? (token.isVscodeTeamMember ? 'vscode' : token.isGitHubInternal ? 'github' : token.isMicrosoftInternal ? 'microsoft' : undefined)
: undefined;

const filters = new Map<string, string | undefined>();
filters.set('vscode_core_platform', PlatformToString(platform));
if (this._releaseDate) {
filters.set('vscode_core_releasedate', this._releaseDate);
}
filters.set('vscode_core_windowkind', vscode.workspace.isAgentSessionsWorkspace ? 'agents' : 'editor');
filters.set('devdeviceid', vscode.env.devDeviceId);
filters.set('copilottrackingid', token?.getTokenValue('tid'));
filters.set('github_core_organizationid', token?.organizationList.join(','));
filters.set('github_core_businessid', token?.enterpriseList.join(','));
filters.set('github_core_isghormsftstaff', internalOrg ? '1' : '0');
filters.set('github_core_ghmsftorexternal', internalOrg === 'github' ? 'github' : (internalOrg === 'microsoft' || internalOrg === 'vscode') ? 'microsoft' : 'external');

this._logService.trace(`[CopilotAssignmentsFilterProvider]::getFilters Filters: ${JSON.stringify(Array.from(filters.entries()))}`);
return filters;
}
}

export class MicrosoftExperimentationService extends BaseExperimentationService {
constructor(
@ITelemetryService telemetryService: ITelemetryService,
Expand All @@ -232,27 +279,55 @@ export class MicrosoftExperimentationService extends BaseExperimentationService
let self: MicrosoftExperimentationService | undefined = undefined;
const delegateFn = (globalState: vscode.Memento, userInfoStore: UserInfoStore) => {
const wrappedMemento = new ExpMementoWrapper(globalState, envService);
return getExperimentationService(
id,
version,
const exp = copilotTokenStore.copilotToken?.endpoints?.exp;
const assignmentsEndpoint = exp ? `${exp.replace(/\/+$/, '')}/api/v1/assignments` : undefined;
// Route the assignments request through the extension's fetcher service so it gets
// proxy handling, retries/fallback, and the standard user-agent for free.
const assignmentsFetch = (url: string, init: { method: 'POST'; headers: Record<string, string>; body: string }) =>
fetcherService.fetch(url, {
method: init.method,
headers: init.headers,
body: init.body,
callSite: 'exp.assignments',
});
return getExperimentationServiceFromConfig({
extensionName: id,
extensionVersion: version,
targetPopulation,
telemetryService,
wrappedMemento,
new GithubAccountFilterProvider(userInfoStore, logService),
new RelatedExtensionsFilterProvider(logService),
new CopilotExtensionsFilterProvider(logService),
// The callback is called in super ctor. At that time, self/this is not initialized yet (but also, no filter could have been possibly set).
new CopilotCompletionsFilterProvider(() => self?.getCompletionsFilters() ?? new Map(), logService),
new DevDeviceIdFilterProvider(vscode.env.devDeviceId),
new PlatformAndReleaseDateFilterProvider(logService),
new WindowKindFilterProvider(logService),
);
telemetry: telemetryService,
memento: wrappedMemento,
filterProviders: [
new GithubAccountFilterProvider(userInfoStore, logService),
new RelatedExtensionsFilterProvider(logService),
new CopilotExtensionsFilterProvider(logService),
// The callback is called in super ctor. At that time, self/this is not initialized yet (but also, no filter could have been possibly set).
new CopilotCompletionsFilterProvider(() => self?.getCompletionsFilters() ?? new Map(), logService),
new DevDeviceIdFilterProvider(vscode.env.devDeviceId),
new PlatformAndReleaseDateFilterProvider(logService),
new WindowKindFilterProvider(logService),
],
assignmentsEndpoint,
assignmentsFilterProviders: assignmentsEndpoint ? [new CopilotAssignmentsFilterProvider(copilotTokenStore, logService)] : undefined,
assignmentsFetch: assignmentsEndpoint ? assignmentsFetch : undefined,
});
};

super(delegateFn, context, copilotTokenStore, configurationService, logService);

self = this; // This is now fully initialized.

// The assignments endpoint is sourced from the Copilot token, which may arrive after
// this service is created. Recreate the delegate when its `exp` endpoint changes.
let currentExp = copilotTokenStore.copilotToken?.endpoints?.exp;
this._register(copilotTokenStore.onDidStoreUpdate(() => {
const token = copilotTokenStore.copilotToken;
const newExp = token?.endpoints?.exp;
if (newExp !== currentExp) {
currentExp = newExp;
this.recreateDelegate();
}
}));

if (fetcherService instanceof FetcherService) {
fetcherService.setExperimentationService(this);
}
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
"playwright-core": "1.61.0-alpha-2026-06-04",
"ssh2": "^1.16.0",
"tar": "^7.5.20",
"tas-client": "0.3.1",
"tas-client": "0.4.2",
"undici": "^7.28.0",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
Expand Down
8 changes: 4 additions & 4 deletions remote/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading