Skip to content

Commit 575e3e4

Browse files
vijayupadyaCopilotCopilot
authored
Handle prefix in exp treatements by new endpoint (#333282)
* Handle prefix in exp treatements by new endpoint * Update comment formatting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Updates to make new endpoint win on collision * Move scoped-treatment tests into a dedicated suite Addresses PR review: the scoped-lookup tests do not exercise delegate recreation, so they belong in their own suite rather than in 'ExP Service delegate recreation'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 15b73982-0eed-4302-a104-a946d0382b4c --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 15b73982-0eed-4302-a104-a946d0382b4c
1 parent 6868dc6 commit 575e3e4

4 files changed

Lines changed: 146 additions & 13 deletions

File tree

extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import { IVSCodeExtensionContext } from '../../extContext/common/extensionContex
1515
import { ILogService } from '../../log/common/logService';
1616
import { IExperimentationService, TreatmentsChangeEvent } from '../common/nullExperimentationService';
1717

18+
/**
19+
* Scope prefix that the new TAS assignments endpoint (`/api/v1/assignments`) prepends to the
20+
* feature variable keys it returns (e.g. `/vscode/config.chat...`). The legacy endpoint and
21+
* callers query treatments by the bare name, so this prefix must be accounted for when a bare
22+
* lookup misses. This is an interim workaround until vscode-tas-client strips the scope itself.
23+
*/
24+
const ASSIGNMENTS_SCOPE_PREFIX = '/vscode/';
25+
1826
export class UserInfoStore extends Disposable {
1927
private _internalOrg: string | undefined;
2028
private _sku: string | undefined;
@@ -244,7 +252,7 @@ export class BaseExperimentationService extends Disposable implements IExperimen
244252
private _signalTreatmentsChangeEvent = () => {
245253
const affectedTreatmentVariables: string[] = [];
246254
for (const [key, previousValue] of this._previouslyReadTreatments) {
247-
const currentValue = this._delegate.getTreatmentVariable('vscode', key);
255+
const currentValue = this._readTreatmentVariable(key);
248256
if (currentValue !== previousValue) {
249257
this._logService.trace(`[BaseExperimentationService] Treatment changed: ${key} from ${previousValue} to ${currentValue}`);
250258
this._previouslyReadTreatments.set(key, currentValue);
@@ -267,11 +275,27 @@ export class BaseExperimentationService extends Disposable implements IExperimen
267275
}
268276

269277
getTreatmentVariable<T extends boolean | number | string>(name: string): T | undefined {
270-
const result = this._delegate.getTreatmentVariable('vscode', name) as T;
278+
const result = this._readTreatmentVariable<T>(name);
271279
this._previouslyReadTreatments.set(name, result);
272280
return result;
273281
}
274282

283+
/**
284+
* Reads a treatment, preferring the `/vscode/`-scoped key over the bare key. Interim workaround
285+
* till its fixed upstream: the new TAS assignments endpoint namespaces its returned feature
286+
* variable keys with a `/vscode/` scope that vscode-tas-client does not strip. Reading the
287+
* scoped key first makes the new endpoint win over the legacy (bare) key when both assign a
288+
* treatment - matching the behavior once the scope is stripped upstream - while still resolving
289+
* legacy-only treatments from the bare key.
290+
*/
291+
private _readTreatmentVariable<T extends boolean | number | string>(name: string): T | undefined {
292+
let result = this._delegate.getTreatmentVariable('vscode', `${ASSIGNMENTS_SCOPE_PREFIX}${name}`) as T | undefined;
293+
if (result === undefined) {
294+
result = this._delegate.getTreatmentVariable('vscode', name) as T | undefined;
295+
}
296+
return result;
297+
}
298+
275299
// Note: This is only temporarily until we have fully migrated to the new completions implementation.
276300
// At that point, we can remove this method and the related code.
277301
private _completionsFilters: Map<string, string> = new Map<string, string>();

extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ class MockTASExperimentationService implements ITASExperimentationService {
135135
return undefined;
136136
}
137137

138+
// This suite models treatments served by the legacy endpoint (bare keys). The new
139+
// assignments endpoint does not assign these, so scoped lookups resolve to undefined,
140+
// exercising the service's bare-key fallback.
141+
if (name.startsWith('/vscode/')) {
142+
return undefined;
143+
}
144+
138145
const org = this.userInfoStore.internalOrg;
139146
const sku = this.userInfoStore.sku;
140147

@@ -826,6 +833,41 @@ describe('ExP Service delegate recreation', () => {
826833
});
827834
});
828835

836+
describe('ExP Service scoped treatment resolution', () => {
837+
let accessor: ITestingServicesAccessor;
838+
839+
beforeAll(() => {
840+
const testingServiceCollection = createPlatformServices();
841+
accessor = testingServiceCollection.createTestingAccessor();
842+
});
843+
844+
const create = () => accessor.get(IInstantiationService).createInstance(RecreatableExperimentationService);
845+
846+
it('resolves a treatment served only under the /vscode/ assignments scope prefix', () => {
847+
const service = create();
848+
const delegate = service.delegates[0];
849+
850+
// The new assignments endpoint returns the key with a `/vscode/` scope prefix only.
851+
delegate.setTreatment('/vscode/config.chat.copilot.subagentModelGuidance.enabled', true);
852+
853+
expect(service.getTreatmentVariable<boolean>('config.chat.copilot.subagentModelGuidance.enabled')).toBe(true);
854+
855+
service.dispose();
856+
});
857+
858+
it('prefers the /vscode/ scoped key (new endpoint) over the bare key on collision', () => {
859+
const service = create();
860+
const delegate = service.delegates[0];
861+
862+
delegate.setTreatment('config.foo', 'bare');
863+
delegate.setTreatment('/vscode/config.foo', 'scoped');
864+
865+
expect(service.getTreatmentVariable<string>('config.foo')).toBe('scoped');
866+
867+
service.dispose();
868+
});
869+
});
870+
829871
/**
830872
* Records every request routed through the fetcher service so a test can assert that both TAS
831873
* endpoints go through it (proxy-aware transport) with the expected method and call site.

src/vs/workbench/services/assignment/common/assignmentService.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ export interface IAssignmentFilter {
4242

4343
export const IWorkbenchAssignmentService = createDecorator<IWorkbenchAssignmentService>('assignmentService');
4444

45+
/**
46+
* Scope prefix that the new TAS assignments endpoint (`/api/v1/assignments`) prepends to the
47+
* feature variable keys it returns (e.g. `/vscode/config.chat...`). The legacy endpoint and
48+
* VS Code both query treatments by the bare name, so this prefix must be accounted for when a
49+
* bare lookup misses. This is an interim workaround until tas-client strips the scope itself.
50+
*/
51+
const ASSIGNMENTS_SCOPE_PREFIX = '/vscode/';
52+
53+
/**
54+
* Resolves a treatment value preferring the `/vscode/`-scoped key emitted by the new TAS
55+
* assignments endpoint over the bare key used by the legacy endpoint, so the new endpoint wins
56+
* when both assign a treatment (matching the behavior once tas-client strips the scope itself).
57+
* Falls back to the bare key for treatments served only by the legacy endpoint.
58+
*
59+
* Exported for testing.
60+
*/
61+
export function resolveScopedTreatment<T extends string | number | boolean>(read: (name: string) => T | undefined, name: string): T | undefined {
62+
const scoped = read(`${ASSIGNMENTS_SCOPE_PREFIX}${name}`);
63+
return scoped !== undefined ? scoped : read(name);
64+
}
65+
4566
export interface IWorkbenchAssignmentService extends IAssignmentService {
4667
getCurrentExperiments(): Promise<string[] | undefined>;
4768
addTelemetryAssignmentFilter(filter: IAssignmentFilter): void;
@@ -268,21 +289,22 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen
268289
return undefined;
269290
}
270291

271-
let result: T | undefined;
272292
const client = await this.tasClient;
273293

274-
// The TAS client is initialized but we need to check if the initial fetch has completed yet
275-
// If it is complete, return a cached value for the treatment
276-
// If not, use the async call with `checkCache: true`. This will allow the module to return a cached value if it is present.
277-
// Otherwise it will await the initial fetch to return the most up to date value.
278-
if (this.networkInitialized) {
279-
result = client.getTreatmentVariable<T>('vscode', name);
280-
} else {
281-
result = await client.getTreatmentVariableAsync<T>('vscode', name, true);
294+
// Await the initial network fetch when it has not completed yet, so treatments are
295+
// available before we read them from memory. `checkCache: true` returns immediately when a
296+
// value is already cached, otherwise it awaits the initial fetch.
297+
if (!this.networkInitialized) {
298+
await client.getTreatmentVariableAsync<T>('vscode', `${ASSIGNMENTS_SCOPE_PREFIX}${name}`, true);
282299
}
283300

284-
result = client.getTreatmentVariable<T>('vscode', name);
285-
return result;
301+
// Interim workaround: the new TAS assignments endpoint (/api/v1/assignments) namespaces its
302+
// returned feature variable keys with a `/vscode/` scope, whereas the legacy endpoint and
303+
// VS Code query treatments by the bare name. Read the scoped key first so the new endpoint
304+
// wins over the legacy (bare) key when both assign a treatment - matching the behavior once
305+
// tas-client strips the scope itself. Fall back to the bare key for treatments served only
306+
// by the legacy endpoint.
307+
return resolveScopedTreatment<T>(readName => client.getTreatmentVariable<T>('vscode', readName), name);
286308
}
287309

288310
/**
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
8+
import { resolveScopedTreatment } from '../../common/assignmentService.js';
9+
10+
suite('resolveScopedTreatment', () => {
11+
12+
ensureNoDisposablesAreLeakedInTestSuite();
13+
14+
const BARE = 'config.chat.agentHost.copilot.multiTurnContextRouting.enabled';
15+
const SCOPED = `/vscode/${BARE}`;
16+
17+
function readFrom(values: Record<string, string | number | boolean>): (name: string) => string | number | boolean | undefined {
18+
return name => values[name];
19+
}
20+
21+
test('prefers the /vscode/ scoped value (new endpoint) over the bare value on collision', () => {
22+
const read = readFrom({ [BARE]: 'legacy', [SCOPED]: 'new' });
23+
assert.strictEqual(resolveScopedTreatment(read, BARE), 'new');
24+
});
25+
26+
test('falls back to the bare value when only the legacy endpoint assigns it', () => {
27+
const read = readFrom({ [BARE]: 'legacy' });
28+
assert.strictEqual(resolveScopedTreatment(read, BARE), 'legacy');
29+
});
30+
31+
test('uses the scoped value when only the new endpoint assigns it', () => {
32+
const read = readFrom({ [SCOPED]: 'new' });
33+
assert.strictEqual(resolveScopedTreatment(read, BARE), 'new');
34+
});
35+
36+
test('returns undefined when neither endpoint assigns it', () => {
37+
const read = readFrom({});
38+
assert.strictEqual(resolveScopedTreatment(read, BARE), undefined);
39+
});
40+
41+
test('preserves a defined falsy scoped value instead of falling back to bare', () => {
42+
const read = readFrom({ [BARE]: true, [SCOPED]: false });
43+
assert.strictEqual(resolveScopedTreatment(read, BARE), false);
44+
});
45+
});

0 commit comments

Comments
 (0)