diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts new file mode 100644 index 0000000000..cb6b6397a4 --- /dev/null +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ExperimentFlags = { + CONTEXT_COMPRESSION_THRESHOLD: + 'GeminiCLIContextCompression__threshold_fraction', + USER_CACHING: 'GcliUserCaching__user_caching', +} as const; + +export type ExperimentFlagName = + (typeof ExperimentFlags)[keyof typeof ExperimentFlags]; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 368f42bd0f..58ce67fe18 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import type { ConfigParameters, SandboxConfig } from './config.js'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js'; +import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; +import { debugLogger } from '../utils/debugLogger.js'; import { ApprovalMode } from '../policy/types.js'; import type { HookDefinition } from '../hooks/types.js'; import { HookType, HookEventName } from '../hooks/types.js'; @@ -247,7 +249,7 @@ describe('Server Config (config.ts)', () => { ...baseParams, experiments: { flags: { - GeminiCLIContextCompression__threshold_fraction: { + [ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]: { floatValue: 0.8, }, }, @@ -261,7 +263,7 @@ describe('Server Config (config.ts)', () => { ...baseParams, experiments: { flags: { - GeminiCLIContextCompression__threshold_fraction: { + [ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]: { floatValue: 0.0, }, }, @@ -275,6 +277,43 @@ describe('Server Config (config.ts)', () => { expect(await config.getCompressionThreshold()).toBeUndefined(); }); }); + + describe('getUserCaching', () => { + it('should return the remote experiment flag when available', async () => { + const config = new Config({ + ...baseParams, + experiments: { + flags: { + [ExperimentFlags.USER_CACHING]: { + boolValue: true, + }, + }, + experimentIds: [], + }, + }); + expect(await config.getUserCaching()).toBe(true); + }); + + it('should return false when the remote flag is false', async () => { + const config = new Config({ + ...baseParams, + experiments: { + flags: { + [ExperimentFlags.USER_CACHING]: { + boolValue: false, + }, + }, + experimentIds: [], + }, + }); + expect(await config.getUserCaching()).toBe(false); + }); + + it('should return undefined if there are no experiments', async () => { + const config = new Config(baseParams); + expect(await config.getUserCaching()).toBeUndefined(); + }); + }); }); describe('refreshAuth', () => { @@ -1482,3 +1521,64 @@ describe('Config getExperiments', () => { expect(retrievedExps).toBe(mockExps); // Should return the same reference }); }); + +describe('Config setExperiments logging', () => { + const baseParams: ConfigParameters = { + cwd: '/tmp', + targetDir: '/path/to/target', + debugMode: false, + sessionId: 'test-session-id', + model: 'gemini-pro', + usageStatisticsEnabled: false, + }; + + it('logs a sorted, non-truncated summary of experiments when they are set', () => { + const config = new Config(baseParams); + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + const experiments = { + flags: { + ZetaFlag: { + boolValue: true, + stringValue: 'zeta', + int32ListValue: { values: [1, 2] }, + }, + AlphaFlag: { + boolValue: false, + stringValue: 'alpha', + stringListValue: { values: ['a', 'b', 'c'] }, + }, + MiddleFlag: { + // Intentionally sparse to ensure undefined values are omitted + floatValue: 0.42, + int32ListValue: { values: [] }, + }, + }, + experimentIds: [101, 99], + }; + + config.setExperiments(experiments); + + const logCall = debugSpy.mock.calls.find( + ([message]) => message === 'Experiments loaded', + ); + expect(logCall).toBeDefined(); + const loggedSummary = logCall?.[1] as string; + expect(typeof loggedSummary).toBe('string'); + expect(loggedSummary).toContain('experimentIds'); + expect(loggedSummary).toContain('101'); + expect(loggedSummary).toContain('AlphaFlag'); + expect(loggedSummary).toContain('ZetaFlag'); + const alphaIndex = loggedSummary.indexOf('AlphaFlag'); + const zetaIndex = loggedSummary.indexOf('ZetaFlag'); + expect(alphaIndex).toBeGreaterThan(-1); + expect(zetaIndex).toBeGreaterThan(-1); + expect(alphaIndex).toBeLessThan(zetaIndex); + expect(loggedSummary).toContain('\n'); + expect(loggedSummary).not.toContain('stringListLength: 0'); + expect(loggedSummary).not.toContain('int32ListLength: 0'); + + debugSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c2196f53c3..9311787617 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -5,6 +5,7 @@ */ import * as path from 'node:path'; +import { inspect } from 'node:util'; import process from 'node:process'; import type { ContentGenerator, @@ -84,6 +85,7 @@ import { AgentRegistry } from '../agents/registry.js'; import { setGlobalProxy } from '../utils/fetch.js'; import { SubagentToolWrapper } from '../agents/subagent-tool-wrapper.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; +import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; import { ApprovalMode } from '../policy/types.js'; @@ -1144,16 +1146,10 @@ export class Config { return this.compressionThreshold; } - if (this.experimentsPromise) { - try { - await this.experimentsPromise; - } catch (e) { - debugLogger.debug('Failed to fetch experiments', e); - } - } + await this.ensureExperimentsLoaded(); const remoteThreshold = - this.experiments?.flags['GeminiCLIContextCompression__threshold_fraction'] + this.experiments?.flags[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD] ?.floatValue; if (remoteThreshold === 0) { return undefined; @@ -1161,6 +1157,23 @@ export class Config { return remoteThreshold; } + async getUserCaching(): Promise { + await this.ensureExperimentsLoaded(); + + return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue; + } + + private async ensureExperimentsLoaded(): Promise { + if (!this.experimentsPromise) { + return; + } + try { + await this.experimentsPromise; + } catch (e) { + debugLogger.debug('Failed to fetch experiments', e); + } + } + isInteractiveShellEnabled(): boolean { return ( this.interactive && @@ -1413,6 +1426,44 @@ export class Config { */ setExperiments(experiments: Experiments): void { this.experiments = experiments; + const flagSummaries = Object.entries(experiments.flags ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, flag]) => { + const summary: Record = { name }; + if (flag.boolValue !== undefined) { + summary['boolValue'] = flag.boolValue; + } + if (flag.floatValue !== undefined) { + summary['floatValue'] = flag.floatValue; + } + if (flag.intValue !== undefined) { + summary['intValue'] = flag.intValue; + } + if (flag.stringValue !== undefined) { + summary['stringValue'] = flag.stringValue; + } + const int32Length = flag.int32ListValue?.values?.length ?? 0; + if (int32Length > 0) { + summary['int32ListLength'] = int32Length; + } + const stringListLength = flag.stringListValue?.values?.length ?? 0; + if (stringListLength > 0) { + summary['stringListLength'] = stringListLength; + } + return summary; + }); + const summary = { + experimentIds: experiments.experimentIds ?? [], + flags: flagSummaries, + }; + const summaryString = inspect(summary, { + depth: null, + maxArrayLength: null, + maxStringLength: null, + breakLength: 80, + compact: false, + }); + debugLogger.debug('Experiments loaded', summaryString); } } // Export model constants for use in CLI