From be1dc13bb12f468614e5c9374512096837d5c281 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 3 Nov 2025 13:51:22 -0800 Subject: [PATCH] feat(core): Add support for listing experiments (#12495) --- .../experiments/client_metadata.ts | 55 +++++++++++++++++ .../code_assist/experiments/experiments.ts | 49 +++++++++++++++ .../core/src/code_assist/experiments/types.ts | 58 ++++++++++++++++++ packages/core/src/code_assist/server.test.ts | 26 ++++++++ packages/core/src/code_assist/server.ts | 22 +++++++ packages/core/src/utils/channel.test.ts | 60 ++++++++++++++++++- packages/core/src/utils/channel.ts | 8 +-- 7 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/code_assist/experiments/client_metadata.ts create mode 100644 packages/core/src/code_assist/experiments/experiments.ts create mode 100644 packages/core/src/code_assist/experiments/types.ts diff --git a/packages/core/src/code_assist/experiments/client_metadata.ts b/packages/core/src/code_assist/experiments/client_metadata.ts new file mode 100644 index 0000000000..953d46aa5b --- /dev/null +++ b/packages/core/src/code_assist/experiments/client_metadata.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getReleaseChannel } from '../../utils/channel.js'; +import type { ClientMetadata, Platform } from './types.js'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Cache all client metadata. +let clientMetadataPromise: Promise | undefined; + +function getPlatform(): Platform { + const platform = process.platform; + const arch = process.arch; + + if (platform === 'darwin' && arch === 'x64') { + return 'DARWIN_AMD64'; + } + if (platform === 'darwin' && arch === 'arm64') { + return 'DARWIN_ARM64'; + } + if (platform === 'linux' && arch === 'x64') { + return 'LINUX_AMD64'; + } + if (platform === 'linux' && arch === 'arm64') { + return 'LINUX_ARM64'; + } + if (platform === 'win32' && arch === 'x64') { + return 'WINDOWS_AMD64'; + } + return 'PLATFORM_UNSPECIFIED'; +} + +/** + * Returns the client metadata. + * + * The client metadata is cached so that it is only computed once per session. + */ +export async function getClientMetadata(): Promise { + if (!clientMetadataPromise) { + clientMetadataPromise = (async () => ({ + ide_type: 'GEMINI_CLI', + ide_version: process.env['CLI_VERSION'] || process.version, + platform: getPlatform(), + update_channel: await getReleaseChannel(__dirname), + }))(); + } + return await clientMetadataPromise; +} diff --git a/packages/core/src/code_assist/experiments/experiments.ts b/packages/core/src/code_assist/experiments/experiments.ts new file mode 100644 index 0000000000..7e4f5543b8 --- /dev/null +++ b/packages/core/src/code_assist/experiments/experiments.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodeAssistServer } from '../server.js'; +import { getClientMetadata } from './client_metadata.js'; +import type { ListExperimentsResponse, Flag } from './types.js'; + +export interface Experiments { + flags: Record; + experimentIds: number[]; +} + +let experimentsPromise: Promise | undefined; + +/** + * Gets the experiments from the server. + * + * The experiments are cached so that they are only fetched once. + */ +export async function getExperiments( + server: CodeAssistServer, +): Promise { + if (experimentsPromise) { + return await experimentsPromise; + } + + experimentsPromise = (async () => { + const metadata = await getClientMetadata(); + const response = await server.listExperiments(metadata); + return parseExperiments(response); + })(); + return await experimentsPromise; +} + +function parseExperiments(response: ListExperimentsResponse): Experiments { + const flags: Record = {}; + for (const flag of response.flags ?? []) { + if (flag.name) { + flags[flag.name] = flag; + } + } + return { + flags, + experimentIds: response.experiment_ids ?? [], + }; +} diff --git a/packages/core/src/code_assist/experiments/types.ts b/packages/core/src/code_assist/experiments/types.ts new file mode 100644 index 0000000000..3c28c71465 --- /dev/null +++ b/packages/core/src/code_assist/experiments/types.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ListExperimentsRequest { + project: string; + metadata?: ClientMetadata; +} + +export interface ListExperimentsResponse { + experiment_ids?: number[]; + flags?: Flag[]; + filtered_flags?: FilteredFlag[]; + debug_string?: string; +} + +export interface Flag { + name?: string; + bool_value?: boolean; + float_value?: number; + int_value?: string; // int64 + string_value?: string; + int32_list_value?: Int32List; + string_list_value?: StringList; +} + +export interface Int32List { + values?: number[]; +} + +export interface StringList { + values?: string[]; +} + +export interface FilteredFlag { + name?: string; + reason?: string; +} + +export interface ClientMetadata { + ide_type?: IdeType; + ide_version?: string; + platform?: Platform; + update_channel?: 'nightly' | 'preview' | 'stable'; + duet_project?: string; +} + +export type IdeType = 'GEMINI_CLI'; + +export type Platform = + | 'PLATFORM_UNSPECIFIED' + | 'DARWIN_AMD64' + | 'DARWIN_ARM64' + | 'LINUX_AMD64' + | 'LINUX_ARM64' + | 'WINDOWS_AMD64'; diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 967493abca..5ef4bbd352 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -252,4 +252,30 @@ describe('CodeAssistServer', () => { currentTier: { id: UserTierId.STANDARD }, }); }); + + it('should call the listExperiments endpoint with metadata', async () => { + const client = new OAuth2Client(); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); + const mockResponse = { + experiments: [], + }; + vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); + + const metadata = { + ide_version: 'v0.1.0', + }; + const response = await server.listExperiments(metadata); + + expect(server.requestPost).toHaveBeenCalledWith('listExperiments', { + project: 'test-project', + metadata: { ide_version: 'v0.1.0', duet_project: 'test-project' }, + }); + expect(response).toEqual(mockResponse); + }); }); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 8859d56083..6c0b68f448 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -14,6 +14,11 @@ import type { OnboardUserRequest, SetCodeAssistGlobalUserSettingRequest, } from './types.js'; +import type { + ListExperimentsRequest, + ListExperimentsResponse, + ClientMetadata, +} from './experiments/types.js'; import type { CountTokensParameters, CountTokensResponse, @@ -149,6 +154,23 @@ export class CodeAssistServer implements ContentGenerator { throw Error(); } + async listExperiments( + metadata: ClientMetadata, + ): Promise { + if (!this.projectId) { + throw new Error('projectId is not defined for CodeAssistServer.'); + } + const projectId = this.projectId; + const req: ListExperimentsRequest = { + project: projectId, + metadata: { ...metadata, duet_project: projectId }, + }; + return await this.requestPost( + 'listExperiments', + req, + ); + } + async requestPost( method: string, req: object, diff --git a/packages/core/src/utils/channel.test.ts b/packages/core/src/utils/channel.test.ts index 1e9ee6f13d..8dc554e4df 100644 --- a/packages/core/src/utils/channel.test.ts +++ b/packages/core/src/utils/channel.test.ts @@ -5,7 +5,14 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { isNightly, isPreview, isStable, _clearCache } from './channel.js'; +import { + ReleaseChannel, + getReleaseChannel, + isNightly, + isPreview, + isStable, + _clearCache, +} from './channel.js'; import * as packageJson from './package.js'; vi.mock('./package.js', () => ({ @@ -132,6 +139,54 @@ describe('channel', () => { }); }); + describe('getReleaseChannel', () => { + it('should return STABLE for a stable version', async () => { + vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({ + name: 'test', + version: '1.0.0', + }); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.STABLE, + ); + }); + + it('should return NIGHTLY for a nightly version', async () => { + vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({ + name: 'test', + version: '1.0.0-nightly.1', + }); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.NIGHTLY, + ); + }); + + it('should return PREVIEW for a preview version', async () => { + vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({ + name: 'test', + version: '1.0.0-preview.1', + }); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.PREVIEW, + ); + }); + + it('should return NIGHTLY if package.json is not found', async () => { + vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue(undefined); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.NIGHTLY, + ); + }); + + it('should return NIGHTLY if version is not defined', async () => { + vi.spyOn(packageJson, 'getPackageJson').mockResolvedValue({ + name: 'test', + }); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.NIGHTLY, + ); + }); + }); + describe('memoization', () => { it('should only call getPackageJson once for the same cwd', async () => { const spy = vi @@ -141,6 +196,9 @@ describe('channel', () => { await expect(isStable('/test/dir')).resolves.toBe(true); await expect(isNightly('/test/dir')).resolves.toBe(false); await expect(isPreview('/test/dir')).resolves.toBe(false); + await expect(getReleaseChannel('/test/dir')).resolves.toBe( + ReleaseChannel.STABLE, + ); expect(spy).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/utils/channel.ts b/packages/core/src/utils/channel.ts index e67e108b2e..09b654b761 100644 --- a/packages/core/src/utils/channel.ts +++ b/packages/core/src/utils/channel.ts @@ -22,7 +22,7 @@ export function _clearCache() { cache.clear(); } -async function _getReleaseChannel(cwd: string): Promise { +export async function getReleaseChannel(cwd: string): Promise { if (cache.has(cwd)) { return cache.get(cwd)!; } @@ -43,13 +43,13 @@ async function _getReleaseChannel(cwd: string): Promise { } export async function isNightly(cwd: string): Promise { - return (await _getReleaseChannel(cwd)) === ReleaseChannel.NIGHTLY; + return (await getReleaseChannel(cwd)) === ReleaseChannel.NIGHTLY; } export async function isPreview(cwd: string): Promise { - return (await _getReleaseChannel(cwd)) === ReleaseChannel.PREVIEW; + return (await getReleaseChannel(cwd)) === ReleaseChannel.PREVIEW; } export async function isStable(cwd: string): Promise { - return (await _getReleaseChannel(cwd)) === ReleaseChannel.STABLE; + return (await getReleaseChannel(cwd)) === ReleaseChannel.STABLE; }