mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3cc1c5682 | |||
| 9804bd6964 | |||
| e88b56bbcb | |||
| 63c6560a38 | |||
| 596a736a99 | |||
| a0ee9bac71 | |||
| e9ea7b3e04 | |||
| 22580dd826 | |||
| d1de82c46f | |||
| 7f8fb6f292 | |||
| dd950088c7 | |||
| 4b4794ad2f |
@@ -901,6 +901,20 @@ Logs keychain availability checks.
|
||||
|
||||
- `available` (boolean)
|
||||
|
||||
##### `gemini_cli.startup_stats`
|
||||
|
||||
Logs detailed startup performance statistics.
|
||||
|
||||
<details>
|
||||
<summary>Attributes</summary>
|
||||
|
||||
- `phases` (json array of startup phases)
|
||||
- `os_platform` (string)
|
||||
- `os_release` (string)
|
||||
- `is_docker` (boolean)
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
### Metrics
|
||||
@@ -917,6 +931,20 @@ Gemini CLI exports several custom metrics.
|
||||
|
||||
Incremented once per CLI startup.
|
||||
|
||||
##### Onboarding
|
||||
|
||||
Tracks onboarding flow from authentication to the user
|
||||
|
||||
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
|
||||
authentication flow begins.
|
||||
|
||||
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
|
||||
onboarding flow completes successfully.
|
||||
<details>
|
||||
<summary>Attributes (Success)</summary>
|
||||
|
||||
- `user_tier` (string)
|
||||
|
||||
##### Tools
|
||||
|
||||
##### `gemini_cli.tool.call.count`
|
||||
|
||||
@@ -1159,7 +1159,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionManagement`** (boolean):
|
||||
|
||||
Generated
+9
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -16231,7 +16231,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
@@ -17414,7 +17413,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17529,7 +17528,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -17701,7 +17700,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -17967,7 +17966,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17982,7 +17981,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17999,7 +17998,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18016,7 +18015,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -325,6 +325,28 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass enableAgents to Config constructor', async () => {
|
||||
const settings: Settings = {
|
||||
experimental: {
|
||||
enableAgents: false,
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to false when not provided', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
describe('interactivity', () => {
|
||||
it('should set interactive true when not headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface Settings {
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -637,64 +637,4 @@ describe('ExtensionManager', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphaned extension cleanup', () => {
|
||||
it('should remove broken extension metadata on startup to allow re-installation', async () => {
|
||||
const extName = 'orphaned-ext';
|
||||
const sourceDir = path.join(tempHomeDir, 'valid-source');
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: extName, version: '1.0.0' }),
|
||||
);
|
||||
|
||||
// Link an extension successfully.
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
source: sourceDir,
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
const destinationPath = path.join(userExtensionsDir, extName);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
'.gemini-extension-install.json',
|
||||
);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
|
||||
// Simulate metadata corruption (e.g., pointing to a non-existent source).
|
||||
fs.writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
|
||||
);
|
||||
|
||||
// Simulate CLI startup. The manager should detect the broken link
|
||||
// and proactively delete the orphaned metadata directory.
|
||||
const newManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
await newManager.loadExtensions();
|
||||
|
||||
// Verify the extension failed to load and was proactively cleaned up.
|
||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(fs.existsSync(destinationPath)).toBe(false);
|
||||
|
||||
// Verify the system is self-healed and allows re-linking to the valid source.
|
||||
await newManager.installOrUpdateExtension({
|
||||
source: sourceDir,
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -982,18 +982,11 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
plan: config.plan,
|
||||
};
|
||||
} catch (e) {
|
||||
const extName = path.basename(extensionDir);
|
||||
debugLogger.warn(
|
||||
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
|
||||
debugLogger.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
try {
|
||||
await fs.promises.rm(extensionDir, { recursive: true, force: true });
|
||||
} catch (rmError) {
|
||||
debugLogger.error(
|
||||
`Failed to remove broken extension directory ${extensionDir}:`,
|
||||
rmError,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +249,10 @@ describe('extension tests', () => {
|
||||
expect(extensions[0].name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'traversal-extension',
|
||||
@@ -660,8 +662,10 @@ name = "yolo-checker"
|
||||
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
|
||||
});
|
||||
|
||||
it('should remove an extension with invalid JSON config and log a warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
it('should skip an extension with invalid JSON config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
createExtension({
|
||||
@@ -682,15 +686,17 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should remove an extension with missing "name" in config and log a warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
it('should skip an extension with missing "name" in config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Good extension
|
||||
createExtension({
|
||||
@@ -711,7 +717,7 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -737,8 +743,10 @@ name = "yolo-checker"
|
||||
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log a warning for invalid extension names during loading', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
it('should log an error for invalid extension names during loading', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'bad_name',
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
|
||||
@@ -1838,7 +1838,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Agents',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -44,6 +44,7 @@ export class AgentRegistry {
|
||||
private readonly agents = new Map<string, AgentDefinition<any>>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly allDefinitions = new Map<string, AgentDefinition<any>>();
|
||||
private readonly builtInAgents = new Set<string>();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@@ -56,6 +57,13 @@ export class AgentRegistry {
|
||||
await this.loadAgents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the agent is a built-in agent.
|
||||
*/
|
||||
isBuiltIn(name: string): boolean {
|
||||
return this.builtInAgents.has(name);
|
||||
}
|
||||
|
||||
private onModelChanged = () => {
|
||||
this.refreshAgents().catch((e) => {
|
||||
debugLogger.error(
|
||||
@@ -240,15 +248,25 @@ export class AgentRegistry {
|
||||
}
|
||||
|
||||
private loadBuiltInAgents(): void {
|
||||
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
|
||||
this.registerLocalAgent(CliHelpAgent(this.config));
|
||||
this.registerLocalAgent(GeneralistAgent(this.config));
|
||||
const codebaseAgent = CodebaseInvestigatorAgent(this.config);
|
||||
this.builtInAgents.add(codebaseAgent.name);
|
||||
this.registerLocalAgent(codebaseAgent);
|
||||
|
||||
const helpAgent = CliHelpAgent(this.config);
|
||||
this.builtInAgents.add(helpAgent.name);
|
||||
this.registerLocalAgent(helpAgent);
|
||||
|
||||
const generalistAgent = GeneralistAgent(this.config);
|
||||
this.builtInAgents.add(generalistAgent.name);
|
||||
this.registerLocalAgent(generalistAgent);
|
||||
|
||||
// Register the browser agent if enabled in settings.
|
||||
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||
const browserConfig = this.config.getBrowserAgentConfig();
|
||||
if (browserConfig.enabled) {
|
||||
this.registerLocalAgent(BrowserAgentDefinition(this.config));
|
||||
const browserAgent = BrowserAgentDefinition(this.config);
|
||||
this.builtInAgents.add(browserAgent.name);
|
||||
this.registerLocalAgent(browserAgent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('codeAssist', () => {
|
||||
projectId: 'test-project',
|
||||
userTier: UserTierId.FREE,
|
||||
userTierName: 'free-tier-name',
|
||||
hasOnboardedPreviously: false,
|
||||
};
|
||||
|
||||
it('should create a server for LOGIN_WITH_GOOGLE', async () => {
|
||||
@@ -63,7 +64,7 @@ describe('codeAssist', () => {
|
||||
);
|
||||
expect(setupUser).toHaveBeenCalledWith(
|
||||
mockAuthClient,
|
||||
mockValidationHandler,
|
||||
mockConfig,
|
||||
httpOptions,
|
||||
);
|
||||
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
|
||||
@@ -95,7 +96,7 @@ describe('codeAssist', () => {
|
||||
);
|
||||
expect(setupUser).toHaveBeenCalledWith(
|
||||
mockAuthClient,
|
||||
mockValidationHandler,
|
||||
mockConfig,
|
||||
httpOptions,
|
||||
);
|
||||
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
|
||||
|
||||
@@ -22,11 +22,7 @@ export async function createCodeAssistContentGenerator(
|
||||
authType === AuthType.COMPUTE_ADC
|
||||
) {
|
||||
const authClient = await getOauthClient(authType, config);
|
||||
const userData = await setupUser(
|
||||
authClient,
|
||||
config.getValidationHandler(),
|
||||
httpOptions,
|
||||
);
|
||||
const userData = await setupUser(authClient, config, httpOptions);
|
||||
return new CodeAssistServer(
|
||||
authClient,
|
||||
userData.projectId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { CodeAssistServer } from '../code_assist/server.js';
|
||||
import type { OAuth2Client } from 'google-auth-library';
|
||||
import { UserTierId, type GeminiUserTier } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
vi.mock('../code_assist/server.js');
|
||||
|
||||
@@ -35,6 +36,8 @@ describe('setupUser', () => {
|
||||
let mockLoad: ReturnType<typeof vi.fn>;
|
||||
let mockOnboardUser: ReturnType<typeof vi.fn>;
|
||||
let mockGetOperation: ReturnType<typeof vi.fn>;
|
||||
let mockConfig: Config;
|
||||
let mockValidationHandler: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -60,6 +63,18 @@ describe('setupUser', () => {
|
||||
getOperation: mockGetOperation,
|
||||
}) as unknown as CodeAssistServer,
|
||||
);
|
||||
|
||||
mockValidationHandler = vi.fn();
|
||||
mockConfig = {
|
||||
getValidationHandler: () => mockValidationHandler,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContentGeneratorConfig: () => ({
|
||||
authType: 'google-login',
|
||||
}),
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => undefined,
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,9 +91,9 @@ describe('setupUser', () => {
|
||||
|
||||
const client = {} as OAuth2Client;
|
||||
// First call
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
// Second call
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -91,10 +106,10 @@ describe('setupUser', () => {
|
||||
|
||||
const client = {} as OAuth2Client;
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p1');
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p2');
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -106,11 +121,11 @@ describe('setupUser', () => {
|
||||
});
|
||||
|
||||
const client = {} as OAuth2Client;
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
vi.advanceTimersByTime(31000); // 31s > 30s expiration
|
||||
|
||||
await setupUser(client);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -123,8 +138,10 @@ describe('setupUser', () => {
|
||||
});
|
||||
|
||||
const client = {} as OAuth2Client;
|
||||
await expect(setupUser(client)).rejects.toThrow('Network error');
|
||||
await setupUser(client);
|
||||
await expect(setupUser(client, mockConfig)).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
await setupUser(client, mockConfig);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -136,7 +153,7 @@ describe('setupUser', () => {
|
||||
mockLoad.mockResolvedValue({
|
||||
currentTier: mockPaidTier,
|
||||
});
|
||||
await setupUser({} as OAuth2Client);
|
||||
await setupUser({} as OAuth2Client, mockConfig);
|
||||
expect(CodeAssistServer).toHaveBeenCalledWith(
|
||||
{},
|
||||
'test-project',
|
||||
@@ -157,7 +174,7 @@ describe('setupUser', () => {
|
||||
'User-Agent': 'GeminiCLI/1.0.0/gemini-2.0-flash (darwin; arm64)',
|
||||
},
|
||||
};
|
||||
await setupUser({} as OAuth2Client, undefined, httpOptions);
|
||||
await setupUser({} as OAuth2Client, mockConfig, httpOptions);
|
||||
expect(CodeAssistServer).toHaveBeenCalledWith(
|
||||
{},
|
||||
'test-project',
|
||||
@@ -174,7 +191,7 @@ describe('setupUser', () => {
|
||||
cloudaicompanionProject: 'server-project',
|
||||
currentTier: mockPaidTier,
|
||||
});
|
||||
const result = await setupUser({} as OAuth2Client);
|
||||
const result = await setupUser({} as OAuth2Client, mockConfig);
|
||||
expect(result.projectId).toBe('server-project');
|
||||
});
|
||||
|
||||
@@ -185,7 +202,7 @@ describe('setupUser', () => {
|
||||
throw new ProjectIdRequiredError();
|
||||
});
|
||||
|
||||
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
|
||||
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
|
||||
ProjectIdRequiredError,
|
||||
);
|
||||
});
|
||||
@@ -197,7 +214,7 @@ describe('setupUser', () => {
|
||||
mockLoad.mockResolvedValue({
|
||||
allowedTiers: [mockPaidTier],
|
||||
});
|
||||
const userData = await setupUser({} as OAuth2Client);
|
||||
const userData = await setupUser({} as OAuth2Client, mockConfig);
|
||||
expect(mockOnboardUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tierId: UserTierId.STANDARD,
|
||||
@@ -208,6 +225,7 @@ describe('setupUser', () => {
|
||||
projectId: 'server-project',
|
||||
userTier: UserTierId.STANDARD,
|
||||
userTierName: 'paid',
|
||||
hasOnboardedPreviously: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,7 +234,7 @@ describe('setupUser', () => {
|
||||
mockLoad.mockResolvedValue({
|
||||
allowedTiers: [mockFreeTier],
|
||||
});
|
||||
const userData = await setupUser({} as OAuth2Client);
|
||||
const userData = await setupUser({} as OAuth2Client, mockConfig);
|
||||
expect(mockOnboardUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tierId: UserTierId.FREE,
|
||||
@@ -227,6 +245,7 @@ describe('setupUser', () => {
|
||||
projectId: 'server-project',
|
||||
userTier: UserTierId.FREE,
|
||||
userTierName: 'free',
|
||||
hasOnboardedPreviously: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,11 +260,12 @@ describe('setupUser', () => {
|
||||
cloudaicompanionProject: undefined,
|
||||
},
|
||||
});
|
||||
const userData = await setupUser({} as OAuth2Client);
|
||||
const userData = await setupUser({} as OAuth2Client, mockConfig);
|
||||
expect(userData).toEqual({
|
||||
projectId: 'test-project',
|
||||
userTier: UserTierId.STANDARD,
|
||||
userTierName: 'paid',
|
||||
hasOnboardedPreviously: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,7 +296,7 @@ describe('setupUser', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const promise = setupUser({} as OAuth2Client);
|
||||
const promise = setupUser({} as OAuth2Client, mockConfig);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
@@ -308,10 +328,10 @@ describe('setupUser', () => {
|
||||
cloudaicompanionProject: 'p1',
|
||||
});
|
||||
|
||||
const mockHandler = vi.fn().mockResolvedValue('verify');
|
||||
const result = await setupUser({} as OAuth2Client, mockHandler);
|
||||
mockValidationHandler.mockResolvedValue('verify');
|
||||
const result = await setupUser({} as OAuth2Client, mockConfig);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledWith(
|
||||
expect(mockValidationHandler).toHaveBeenCalledWith(
|
||||
'https://verify',
|
||||
'Verify please',
|
||||
);
|
||||
@@ -333,9 +353,9 @@ describe('setupUser', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const mockHandler = vi.fn().mockResolvedValue('cancel');
|
||||
mockValidationHandler.mockResolvedValue('cancel');
|
||||
|
||||
await expect(setupUser({} as OAuth2Client, mockHandler)).rejects.toThrow(
|
||||
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
|
||||
ValidationCancelledError,
|
||||
);
|
||||
});
|
||||
@@ -343,7 +363,7 @@ describe('setupUser', () => {
|
||||
it('should throw error if LoadCodeAssist returns empty response', async () => {
|
||||
mockLoad.mockResolvedValue(null);
|
||||
|
||||
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
|
||||
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
|
||||
'LoadCodeAssist returned empty response',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,11 +15,17 @@ import {
|
||||
} from './types.js';
|
||||
import { CodeAssistServer, type HttpOptions } from './server.js';
|
||||
import type { AuthClient } from 'google-auth-library';
|
||||
import type { ValidationHandler } from '../fallback/types.js';
|
||||
import { ChangeAuthRequestedError } from '../utils/errors.js';
|
||||
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { createCache, type CacheService } from '../utils/cache.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
logOnboardingStart,
|
||||
logOnboardingSuccess,
|
||||
OnboardingStartEvent,
|
||||
OnboardingSuccessEvent,
|
||||
} from '../telemetry/index.js';
|
||||
|
||||
export class ProjectIdRequiredError extends Error {
|
||||
constructor() {
|
||||
@@ -54,6 +60,7 @@ export interface UserData {
|
||||
userTier: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
hasOnboardedPreviously?: boolean;
|
||||
}
|
||||
|
||||
// Cache to store the results of setupUser to avoid redundant network calls.
|
||||
@@ -94,7 +101,8 @@ export function resetUserDataCacheForTesting() {
|
||||
* retry, auth change, or cancellation.
|
||||
*
|
||||
* @param client - The authenticated client to use for API calls
|
||||
* @param validationHandler - Optional handler for account validation flow
|
||||
* @param config - The CLI configuration
|
||||
* @param httpOptions - Optional HTTP options
|
||||
* @returns The user's project ID, tier ID, and tier name
|
||||
* @throws {ValidationRequiredError} If account validation is required
|
||||
* @throws {ProjectIdRequiredError} If no project ID is available and required
|
||||
@@ -103,7 +111,7 @@ export function resetUserDataCacheForTesting() {
|
||||
*/
|
||||
export async function setupUser(
|
||||
client: AuthClient,
|
||||
validationHandler?: ValidationHandler,
|
||||
config: Config,
|
||||
httpOptions: HttpOptions = {},
|
||||
): Promise<UserData> {
|
||||
const projectId =
|
||||
@@ -119,7 +127,7 @@ export async function setupUser(
|
||||
);
|
||||
|
||||
return projectCache.getOrCreate(projectId, () =>
|
||||
_doSetupUser(client, projectId, validationHandler, httpOptions),
|
||||
_doSetupUser(client, projectId, config, httpOptions),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +137,7 @@ export async function setupUser(
|
||||
async function _doSetupUser(
|
||||
client: AuthClient,
|
||||
projectId: string | undefined,
|
||||
validationHandler?: ValidationHandler,
|
||||
config: Config,
|
||||
httpOptions: HttpOptions = {},
|
||||
): Promise<UserData> {
|
||||
const caServer = new CodeAssistServer(
|
||||
@@ -146,6 +154,8 @@ async function _doSetupUser(
|
||||
pluginType: 'GEMINI',
|
||||
};
|
||||
|
||||
const validationHandler = config.getValidationHandler();
|
||||
|
||||
let loadRes: LoadCodeAssistResponse;
|
||||
while (true) {
|
||||
loadRes = await caServer.loadCodeAssist({
|
||||
@@ -194,6 +204,8 @@ async function _doSetupUser(
|
||||
UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
hasOnboardedPreviously:
|
||||
loadRes.currentTier.hasOnboardedPreviously ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +218,8 @@ async function _doSetupUser(
|
||||
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
hasOnboardedPreviously:
|
||||
loadRes.currentTier.hasOnboardedPreviously ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,6 +250,8 @@ async function _doSetupUser(
|
||||
};
|
||||
}
|
||||
|
||||
logOnboardingStart(config, new OnboardingStartEvent());
|
||||
|
||||
let lroRes = await caServer.onboardUser(onboardReq);
|
||||
if (!lroRes.done && lroRes.name) {
|
||||
const operationName = lroRes.name;
|
||||
@@ -245,12 +261,16 @@ async function _doSetupUser(
|
||||
}
|
||||
}
|
||||
|
||||
const userTier = tier.id ?? UserTierId.STANDARD;
|
||||
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
|
||||
|
||||
if (!lroRes.response?.cloudaicompanionProject?.id) {
|
||||
if (projectId) {
|
||||
return {
|
||||
projectId,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,6 +281,7 @@ async function _doSetupUser(
|
||||
projectId: lroRes.response.cloudaicompanionProject.id,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ vi.mock('../agents/registry.js', () => {
|
||||
AgentRegistryMock.prototype.initialize = vi.fn();
|
||||
AgentRegistryMock.prototype.getAllDefinitions = vi.fn(() => []);
|
||||
AgentRegistryMock.prototype.getDefinition = vi.fn();
|
||||
AgentRegistryMock.prototype.isBuiltIn = vi.fn();
|
||||
return { AgentRegistry: AgentRegistryMock };
|
||||
});
|
||||
|
||||
@@ -1262,6 +1263,9 @@ describe('Server Config (config.ts)', () => {
|
||||
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
|
||||
mockAgentDefinition,
|
||||
]);
|
||||
AgentRegistryMock.prototype.isBuiltIn.mockImplementation(
|
||||
(name: string) => name === 'codebase_investigator',
|
||||
);
|
||||
|
||||
const SubAgentToolMock = (
|
||||
(await vi.importMock('../agents/subagent-tool.js')) as {
|
||||
@@ -1317,6 +1321,9 @@ describe('Server Config (config.ts)', () => {
|
||||
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
|
||||
mockAgentDefinition,
|
||||
]);
|
||||
AgentRegistryMock.prototype.isBuiltIn.mockImplementation(
|
||||
(name: string) => name === 'codebase_investigator',
|
||||
);
|
||||
|
||||
const SubAgentToolMock = (
|
||||
(await vi.importMock('../agents/subagent-tool.js')) as {
|
||||
|
||||
@@ -951,7 +951,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.model = params.model;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
@@ -3196,7 +3196,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
if (
|
||||
!this.isAgentsEnabled() ||
|
||||
(!this.isAgentsEnabled() &&
|
||||
!this.agentRegistry.isBuiltIn(definition.name)) ||
|
||||
agentsOverrides[definition.name]?.enabled === false
|
||||
) {
|
||||
continue;
|
||||
|
||||
@@ -131,6 +131,10 @@ describe('createContentGenerator', () => {
|
||||
|
||||
// Set a fixed version for testing
|
||||
vi.stubEnv('CLI_VERSION', '1.2.3');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
vi.stubEnv('VSCODE_PID', '');
|
||||
vi.stubEnv('GITHUB_SHA', '');
|
||||
vi.stubEnv('GEMINI_CLI_SURFACE', '');
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
@@ -149,7 +153,7 @@ describe('createContentGenerator', () => {
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.stringMatching(
|
||||
/GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; .*\)/,
|
||||
/GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
@@ -159,7 +163,7 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include clientName prefix in User-Agent when specified', async () => {
|
||||
it('should use standard User-Agent for a2a-server running outside VS Code', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
@@ -169,6 +173,10 @@ describe('createContentGenerator', () => {
|
||||
|
||||
// Set a fixed version for testing
|
||||
vi.stubEnv('CLI_VERSION', '1.2.3');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
vi.stubEnv('VSCODE_PID', '');
|
||||
vi.stubEnv('GITHUB_SHA', '');
|
||||
vi.stubEnv('GEMINI_CLI_SURFACE', '');
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
@@ -185,7 +193,7 @@ describe('createContentGenerator', () => {
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.stringMatching(
|
||||
/GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/,
|
||||
/GeminiCLI-a2a-server\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
@@ -193,6 +201,113 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include unified User-Agent for a2a-server (VS Code Agent Mode)', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getClientName: vi.fn().mockReturnValue('a2a-server'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Set a fixed version for testing
|
||||
vi.stubEnv('CLI_VERSION', '1.2.3');
|
||||
// Mock the environment variable that the VS Code extension host would provide to the a2a-server process
|
||||
vi.stubEnv('VSCODE_PID', '12345');
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('TERM_PROGRAM_VERSION', '1.85.0');
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
await createContentGenerator(
|
||||
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
|
||||
mockConfig,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.stringMatching(
|
||||
/CloudCodeVSCode\/1\.2\.3 \(aidev_client; os_type=.*; os_version=.*; arch=.*; host_path=VSCode\/1\.85\.0; proxy_client=geminicli\)/,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include clientName prefix in User-Agent when specified (non-VSCode)', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getClientName: vi.fn().mockReturnValue('my-client'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Set a fixed version for testing
|
||||
vi.stubEnv('CLI_VERSION', '1.2.3');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
vi.stubEnv('VSCODE_PID', '');
|
||||
vi.stubEnv('GITHUB_SHA', '');
|
||||
vi.stubEnv('GEMINI_CLI_SURFACE', '');
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
await createContentGenerator(
|
||||
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
|
||||
mockConfig,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': expect.stringMatching(
|
||||
/GeminiCLI-my-client\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow custom headers to override User-Agent', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.stubEnv('GEMINI_CLI_CUSTOM_HEADERS', 'User-Agent:MyCustomUA');
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
await createContentGenerator(
|
||||
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
|
||||
mockConfig,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': 'MyCustomUA',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => {
|
||||
const mockGenerator = {} as unknown as ContentGenerator;
|
||||
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
type EmbedContentResponse,
|
||||
type EmbedContentParameters,
|
||||
} from '@google/genai';
|
||||
import * as os from 'node:os';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { isCloudShell } from '../ide/detect-ide.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
|
||||
@@ -175,19 +177,46 @@ export async function createContentGenerator(
|
||||
const customHeadersEnv =
|
||||
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
|
||||
const clientName = gcConfig.getClientName();
|
||||
const userAgentPrefix = clientName
|
||||
? `GeminiCLI-${clientName}`
|
||||
: 'GeminiCLI';
|
||||
const surface = determineSurface();
|
||||
const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
|
||||
|
||||
let userAgent: string;
|
||||
// Use unified format for VS Code traffic.
|
||||
// Note: We don't automatically assume a2a-server is VS Code,
|
||||
// as it could be used by other clients unless the surface explicitly says 'vscode'.
|
||||
if (clientName === 'acp-vscode' || surface === 'vscode') {
|
||||
const osTypeMap: Record<string, string> = {
|
||||
darwin: 'macOS',
|
||||
win32: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
const osType = osTypeMap[process.platform] || process.platform;
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
|
||||
const vscodeVersion = process.env['TERM_PROGRAM_VERSION'] || 'unknown';
|
||||
let hostPath = `VSCode/${vscodeVersion}`;
|
||||
if (isCloudShell()) {
|
||||
const cloudShellVersion =
|
||||
process.env['CLOUD_SHELL_VERSION'] || 'unknown';
|
||||
hostPath += ` > CloudShell/${cloudShellVersion}`;
|
||||
}
|
||||
|
||||
userAgent = `CloudCodeVSCode/${version} (aidev_client; os_type=${osType}; os_version=${osVersion}; arch=${arch}; host_path=${hostPath}; proxy_client=geminicli)`;
|
||||
} else {
|
||||
const userAgentPrefix = clientName
|
||||
? `GeminiCLI-${clientName}`
|
||||
: 'GeminiCLI';
|
||||
userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
|
||||
}
|
||||
|
||||
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
|
||||
const apiKeyAuthMechanism =
|
||||
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
|
||||
const apiVersionEnv = process.env['GOOGLE_GENAI_API_VERSION'];
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...customHeadersMap,
|
||||
'User-Agent': userAgent,
|
||||
...customHeadersMap,
|
||||
};
|
||||
|
||||
if (
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
AgentFinishEvent,
|
||||
WebFetchFallbackAttemptEvent,
|
||||
HookCallEvent,
|
||||
OnboardingStartEvent,
|
||||
OnboardingSuccessEvent,
|
||||
} from '../types.js';
|
||||
import { HookType } from '../../hooks/types.js';
|
||||
import { AgentTerminateMode } from '../../agents/types.js';
|
||||
@@ -1652,4 +1654,38 @@ describe('ClearcutLogger', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logOnboardingStartEvent', () => {
|
||||
it('logs an event with proper name and start key', () => {
|
||||
const { logger } = setup();
|
||||
const event = new OnboardingStartEvent();
|
||||
|
||||
logger?.logOnboardingStartEvent(event);
|
||||
|
||||
const events = getEvents(logger!);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_START);
|
||||
expect(events[0]).toHaveMetadataValue([
|
||||
EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
|
||||
'true',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logOnboardingSuccessEvent', () => {
|
||||
it('logs an event with proper name and user tier', () => {
|
||||
const { logger } = setup();
|
||||
const event = new OnboardingSuccessEvent('standard-tier');
|
||||
|
||||
logger?.logOnboardingSuccessEvent(event);
|
||||
|
||||
const events = getEvents(logger!);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_SUCCESS);
|
||||
expect(events[0]).toHaveMetadataValue([
|
||||
EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
|
||||
'standard-tier',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ import type {
|
||||
KeychainAvailabilityEvent,
|
||||
TokenStorageInitializationEvent,
|
||||
StartupStatsEvent,
|
||||
OnboardingStartEvent,
|
||||
OnboardingSuccessEvent,
|
||||
} from '../types.js';
|
||||
import type {
|
||||
CreditsUsedEvent,
|
||||
@@ -124,6 +126,8 @@ export enum EventNames {
|
||||
TOOL_OUTPUT_MASKING = 'tool_output_masking',
|
||||
KEYCHAIN_AVAILABILITY = 'keychain_availability',
|
||||
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
|
||||
ONBOARDING_START = 'onboarding_start',
|
||||
ONBOARDING_SUCCESS = 'onboarding_success',
|
||||
CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
|
||||
CONSECA_VERDICT = 'conseca_verdict',
|
||||
STARTUP_STATS = 'startup_stats',
|
||||
@@ -1791,6 +1795,33 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logOnboardingStartEvent(_event: OnboardingStartEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
|
||||
value: 'true',
|
||||
},
|
||||
];
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.ONBOARDING_START, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logOnboardingSuccessEvent(event: OnboardingSuccessEvent): void {
|
||||
const data: EventValue[] = [];
|
||||
if (event.userTier) {
|
||||
data.push({
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
|
||||
value: event.userTier,
|
||||
});
|
||||
}
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.ONBOARDING_SUCCESS, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logStartupStatsEvent(event: StartupStatsEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Defines valid event metadata keys for Clearcut logging.
|
||||
export enum EventMetadataKey {
|
||||
// Deleted enums: 24
|
||||
// Next ID: 191
|
||||
// Next ID: 194
|
||||
|
||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||
|
||||
@@ -709,4 +709,14 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage).
|
||||
GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190,
|
||||
|
||||
// ==========================================================================
|
||||
// Gemini Enterprise (GE) Event Keys
|
||||
// ==========================================================================
|
||||
|
||||
// Logs the start of the onboarding process.
|
||||
GEMINI_CLI_ONBOARDING_START = 192,
|
||||
|
||||
// Logs the user tier for onboarding success events.
|
||||
GEMINI_CLI_ONBOARDING_USER_TIER = 193,
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
logWebFetchFallbackAttempt,
|
||||
logNetworkRetryAttempt,
|
||||
logRewind,
|
||||
logOnboardingStart,
|
||||
logOnboardingSuccess,
|
||||
} from './loggers.js';
|
||||
export {
|
||||
logConsecaPolicyGeneration,
|
||||
@@ -70,6 +72,8 @@ export {
|
||||
NetworkRetryAttemptEvent,
|
||||
ToolCallDecision,
|
||||
RewindEvent,
|
||||
OnboardingStartEvent,
|
||||
OnboardingSuccessEvent,
|
||||
ConsecaPolicyGenerationEvent,
|
||||
ConsecaVerdictEvent,
|
||||
} from './types.js';
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
logNetworkRetryAttempt,
|
||||
logExtensionUpdateEvent,
|
||||
logHookCall,
|
||||
logOnboardingStart,
|
||||
logOnboardingSuccess,
|
||||
} from './loggers.js';
|
||||
import { ToolCallDecision } from './tool-call-decision.js';
|
||||
import {
|
||||
@@ -72,6 +74,8 @@ import {
|
||||
EVENT_WEB_FETCH_FALLBACK_ATTEMPT,
|
||||
EVENT_INVALID_CHUNK,
|
||||
EVENT_NETWORK_RETRY_ATTEMPT,
|
||||
EVENT_ONBOARDING_START,
|
||||
EVENT_ONBOARDING_SUCCESS,
|
||||
ApiErrorEvent,
|
||||
ApiRequestEvent,
|
||||
ApiResponseEvent,
|
||||
@@ -98,6 +102,8 @@ import {
|
||||
EVENT_EXTENSION_UPDATE,
|
||||
HookCallEvent,
|
||||
EVENT_HOOK_CALL,
|
||||
OnboardingStartEvent,
|
||||
OnboardingSuccessEvent,
|
||||
LlmRole,
|
||||
} from './types.js';
|
||||
import { HookType } from '../hooks/types.js';
|
||||
@@ -2482,6 +2488,76 @@ describe('loggers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logOnboardingStart', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingStartEvent');
|
||||
vi.spyOn(metrics, 'recordOnboardingStart');
|
||||
});
|
||||
|
||||
it('should log onboarding start event to Clearcut and OTEL, and record metrics', () => {
|
||||
const event = new OnboardingStartEvent();
|
||||
|
||||
logOnboardingStart(mockConfig, event);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logOnboardingStartEvent,
|
||||
).toHaveBeenCalledWith(event);
|
||||
|
||||
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||
body: 'Onboarding started.',
|
||||
attributes: {
|
||||
'session.id': 'test-session-id',
|
||||
'user.email': 'test-user@example.com',
|
||||
'installation.id': 'test-installation-id',
|
||||
'event.name': EVENT_ONBOARDING_START,
|
||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||
interactive: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(metrics.recordOnboardingStart).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logOnboardingSuccess', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingSuccessEvent');
|
||||
vi.spyOn(metrics, 'recordOnboardingSuccess');
|
||||
});
|
||||
|
||||
it('should log onboarding success event to Clearcut and OTEL, and record metrics', () => {
|
||||
const event = new OnboardingSuccessEvent('standard-tier');
|
||||
|
||||
logOnboardingSuccess(mockConfig, event);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logOnboardingSuccessEvent,
|
||||
).toHaveBeenCalledWith(event);
|
||||
|
||||
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||
body: 'Onboarding succeeded. Tier: standard-tier',
|
||||
attributes: {
|
||||
'session.id': 'test-session-id',
|
||||
'user.email': 'test-user@example.com',
|
||||
'installation.id': 'test-installation-id',
|
||||
'event.name': EVENT_ONBOARDING_SUCCESS,
|
||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||
interactive: false,
|
||||
user_tier: 'standard-tier',
|
||||
},
|
||||
});
|
||||
|
||||
expect(metrics.recordOnboardingSuccess).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
'standard-tier',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telemetry Buffering', () => {
|
||||
it('should buffer events when SDK is not initialized', async () => {
|
||||
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
type ToolOutputMaskingEvent,
|
||||
type KeychainAvailabilityEvent,
|
||||
type TokenStorageInitializationEvent,
|
||||
type OnboardingStartEvent,
|
||||
type OnboardingSuccessEvent,
|
||||
} from './types.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -79,6 +81,8 @@ import {
|
||||
recordKeychainAvailability,
|
||||
recordTokenStorageInitialization,
|
||||
recordInvalidChunk,
|
||||
recordOnboardingStart,
|
||||
recordOnboardingSuccess,
|
||||
} from './metrics.js';
|
||||
import { bufferTelemetryEvent } from './sdk.js';
|
||||
import { uiTelemetryService, type UiEvent } from './uiTelemetry.js';
|
||||
@@ -871,6 +875,40 @@ export function logTokenStorageInitialization(
|
||||
});
|
||||
}
|
||||
|
||||
export function logOnboardingStart(
|
||||
config: Config,
|
||||
event: OnboardingStartEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logOnboardingStartEvent(event);
|
||||
bufferTelemetryEvent(() => {
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
|
||||
recordOnboardingStart(config);
|
||||
});
|
||||
}
|
||||
|
||||
export function logOnboardingSuccess(
|
||||
config: Config,
|
||||
event: OnboardingSuccessEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logOnboardingSuccessEvent(event);
|
||||
bufferTelemetryEvent(() => {
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
|
||||
recordOnboardingSuccess(config, event.userTier);
|
||||
});
|
||||
}
|
||||
|
||||
export function logBillingEvent(
|
||||
config: Config,
|
||||
event: BillingTelemetryEvent,
|
||||
|
||||
@@ -51,6 +51,8 @@ const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
|
||||
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
|
||||
const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count';
|
||||
const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count';
|
||||
const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
|
||||
const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
|
||||
|
||||
// Agent Metrics
|
||||
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
|
||||
@@ -299,6 +301,20 @@ const COUNTER_DEFINITIONS = {
|
||||
model: string;
|
||||
},
|
||||
},
|
||||
[EVENT_ONBOARDING_START]: {
|
||||
description: 'Counts onboarding started',
|
||||
valueType: ValueType.INT,
|
||||
assign: (c: Counter) => (onboardingStartCounter = c),
|
||||
attributes: {} as Record<string, never>,
|
||||
},
|
||||
[EVENT_ONBOARDING_SUCCESS]: {
|
||||
description: 'Counts onboarding succeeded',
|
||||
valueType: ValueType.INT,
|
||||
assign: (c: Counter) => (onboardingSuccessCounter = c),
|
||||
attributes: {} as {
|
||||
user_tier?: string;
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const HISTOGRAM_DEFINITIONS = {
|
||||
@@ -640,6 +656,8 @@ let keychainAvailabilityCounter: Counter | undefined;
|
||||
let tokenStorageTypeCounter: Counter | undefined;
|
||||
let overageOptionCounter: Counter | undefined;
|
||||
let creditPurchaseCounter: Counter | undefined;
|
||||
let onboardingStartCounter: Counter | undefined;
|
||||
let onboardingSuccessCounter: Counter | undefined;
|
||||
|
||||
// OpenTelemetry GenAI Semantic Convention Metrics
|
||||
let genAiClientTokenUsageHistogram: Histogram | undefined;
|
||||
@@ -812,6 +830,31 @@ export function recordLinesChanged(
|
||||
|
||||
// --- New Metric Recording Functions ---
|
||||
|
||||
/**
|
||||
* Records a metric for when the Google auth process starts.
|
||||
*/
|
||||
export function recordOnboardingStart(config: Config): void {
|
||||
if (!onboardingStartCounter || !isMetricsInitialized) return;
|
||||
onboardingStartCounter.add(
|
||||
1,
|
||||
baseMetricDefinition.getCommonAttributes(config),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for when the Google auth process ends successfully.
|
||||
*/
|
||||
export function recordOnboardingSuccess(
|
||||
config: Config,
|
||||
userTier?: string,
|
||||
): void {
|
||||
if (!onboardingSuccessCounter || !isMetricsInitialized) return;
|
||||
onboardingSuccessCounter.add(1, {
|
||||
...baseMetricDefinition.getCommonAttributes(config),
|
||||
...(userTier && { user_tier: userTier }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a metric for when a UI frame flickers.
|
||||
*/
|
||||
|
||||
@@ -344,9 +344,9 @@ export async function initializeTelemetry(
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.log('OpenTelemetry SDK started successfully.');
|
||||
}
|
||||
telemetryInitialized = true;
|
||||
activeTelemetryEmail = credentials?.client_email;
|
||||
initializeMetrics(config);
|
||||
telemetryInitialized = true;
|
||||
void flushTelemetryBuffer();
|
||||
} catch (error) {
|
||||
debugLogger.error('Error starting OpenTelemetry SDK:', error);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
|
||||
import { LlmRole } from './llmRole.js';
|
||||
export { LlmRole };
|
||||
import type { HookType } from '../hooks/types.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
|
||||
export interface BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
@@ -2357,6 +2358,55 @@ export class KeychainAvailabilityEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
|
||||
export class OnboardingStartEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'onboarding_start';
|
||||
'event.timestamp': string;
|
||||
|
||||
constructor() {
|
||||
this['event.name'] = 'onboarding_start';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_ONBOARDING_START,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return 'Onboarding started.';
|
||||
}
|
||||
}
|
||||
|
||||
export const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
|
||||
export class OnboardingSuccessEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'onboarding_success';
|
||||
'event.timestamp': string;
|
||||
userTier?: UserTierId;
|
||||
|
||||
constructor(userTier?: UserTierId) {
|
||||
this['event.name'] = 'onboarding_success';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.userTier = userTier;
|
||||
}
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
'event.name': EVENT_ONBOARDING_SUCCESS,
|
||||
'event.timestamp': this['event.timestamp'],
|
||||
user_tier: this.userTier ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Onboarding succeeded.${this.userTier ? ` Tier: ${this.userTier}` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const EVENT_TOKEN_STORAGE_INITIALIZATION =
|
||||
'gemini_cli.token_storage.initialization';
|
||||
export class TokenStorageInitializationEvent implements BaseTelemetryEvent {
|
||||
|
||||
@@ -740,7 +740,7 @@ included directory memory
|
||||
});
|
||||
|
||||
describe('getEnvironmentMemoryPaths', () => {
|
||||
it('should NOT traverse upward beyond trusted root (even with .git)', async () => {
|
||||
it('should traverse upward from trusted root to git root', async () => {
|
||||
// Setup: /temp/parent/repo/.git
|
||||
const parentDir = await createEmptyDir(path.join(testRootDir, 'parent'));
|
||||
const repoDir = await createEmptyDir(path.join(parentDir, 'repo'));
|
||||
@@ -751,7 +751,7 @@ included directory memory
|
||||
path.join(parentDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Parent content',
|
||||
);
|
||||
await createTestFile(
|
||||
const repoFile = await createTestFile(
|
||||
path.join(repoDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Repo content',
|
||||
);
|
||||
@@ -760,15 +760,16 @@ included directory memory
|
||||
'Src content',
|
||||
);
|
||||
|
||||
// Trust srcDir. Should ONLY load srcFile.
|
||||
// Repo and Parent are NOT trusted.
|
||||
// Trust srcDir. Should load srcFile AND repoFile (git root),
|
||||
// but NOT parentFile (above git root).
|
||||
const result = await getEnvironmentMemoryPaths([srcDir]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(srcFile);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(repoFile);
|
||||
expect(result).toContain(srcFile);
|
||||
});
|
||||
|
||||
it('should NOT traverse upward beyond trusted root (no .git)', async () => {
|
||||
it('should fall back to trusted root as ceiling when no .git exists', async () => {
|
||||
// Setup: /homedir/docs/notes (no .git anywhere)
|
||||
const docsDir = await createEmptyDir(path.join(homedir, 'docs'));
|
||||
const notesDir = await createEmptyDir(path.join(docsDir, 'notes'));
|
||||
@@ -782,12 +783,12 @@ included directory memory
|
||||
'Docs content',
|
||||
);
|
||||
|
||||
// Trust notesDir. Should load NOTHING because notesDir has no file,
|
||||
// and we do not traverse up to docsDir.
|
||||
// No .git, so ceiling falls back to the trusted root itself.
|
||||
// notesDir has no GEMINI.md and won't traverse up to docsDir.
|
||||
const resultNotes = await getEnvironmentMemoryPaths([notesDir]);
|
||||
expect(resultNotes).toHaveLength(0);
|
||||
|
||||
// Trust docsDir. Should load docsFile, but NOT homeFile.
|
||||
// docsDir has a GEMINI.md at the trusted root itself, so it's found.
|
||||
const resultDocs = await getEnvironmentMemoryPaths([docsDir]);
|
||||
expect(resultDocs).toHaveLength(1);
|
||||
expect(resultDocs[0]).toBe(docsFile);
|
||||
@@ -809,6 +810,34 @@ included directory memory
|
||||
expect(result[0]).toBe(repoFile);
|
||||
});
|
||||
|
||||
it('should recognize .git as a file (submodules/worktrees)', async () => {
|
||||
const repoDir = await createEmptyDir(
|
||||
path.join(testRootDir, 'worktree_repo'),
|
||||
);
|
||||
// .git as a file, like in submodules and worktrees
|
||||
await createTestFile(
|
||||
path.join(repoDir, '.git'),
|
||||
'gitdir: /some/other/path/.git/worktrees/worktree_repo',
|
||||
);
|
||||
const srcDir = await createEmptyDir(path.join(repoDir, 'src'));
|
||||
|
||||
const repoFile = await createTestFile(
|
||||
path.join(repoDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Repo content',
|
||||
);
|
||||
const srcFile = await createTestFile(
|
||||
path.join(srcDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Src content',
|
||||
);
|
||||
|
||||
// Trust srcDir. Should traverse up to repoDir (git root via .git file).
|
||||
const result = await getEnvironmentMemoryPaths([srcDir]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(repoFile);
|
||||
expect(result).toContain(srcFile);
|
||||
});
|
||||
|
||||
it('should keep multiple memory files from the same directory adjacent and in order', async () => {
|
||||
// Configure multiple memory filenames
|
||||
setGeminiMdFilename(['PRIMARY.md', 'SECONDARY.md']);
|
||||
@@ -1008,6 +1037,7 @@ included directory memory
|
||||
describe('loadJitSubdirectoryMemory', () => {
|
||||
it('should load JIT memory when target is inside a trusted root', async () => {
|
||||
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
|
||||
await createEmptyDir(path.join(rootDir, '.git'));
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
|
||||
const targetFile = path.join(subDir, 'target.txt');
|
||||
|
||||
@@ -1052,6 +1082,7 @@ included directory memory
|
||||
|
||||
it('should skip already loaded paths', async () => {
|
||||
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
|
||||
await createEmptyDir(path.join(rootDir, '.git'));
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
|
||||
const targetFile = path.join(subDir, 'target.txt');
|
||||
|
||||
@@ -1080,6 +1111,7 @@ included directory memory
|
||||
|
||||
it('should deduplicate files in JIT memory loading (same inode)', async () => {
|
||||
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
|
||||
await createEmptyDir(path.join(rootDir, '.git'));
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
|
||||
const targetFile = path.join(subDir, 'target.txt');
|
||||
|
||||
@@ -1131,6 +1163,7 @@ included directory memory
|
||||
|
||||
it('should use the deepest trusted root when multiple nested roots exist', async () => {
|
||||
const outerRoot = await createEmptyDir(path.join(testRootDir, 'outer'));
|
||||
await createEmptyDir(path.join(outerRoot, '.git'));
|
||||
const innerRoot = await createEmptyDir(path.join(outerRoot, 'inner'));
|
||||
const targetFile = path.join(innerRoot, 'target.txt');
|
||||
|
||||
@@ -1149,17 +1182,18 @@ included directory memory
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].path).toBe(innerMemory);
|
||||
expect(result.files[0].content).toBe('Inner content');
|
||||
// Ensure outer memory is NOT loaded
|
||||
expect(result.files.find((f) => f.path === outerMemory)).toBeUndefined();
|
||||
// Traversal goes from innerRoot (deepest trusted root) up to outerRoot
|
||||
// (git root), so both files are found.
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files.find((f) => f.path === innerMemory)).toBeDefined();
|
||||
expect(result.files.find((f) => f.path === outerMemory)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve file target to its parent directory for traversal', async () => {
|
||||
const rootDir = await createEmptyDir(
|
||||
path.join(testRootDir, 'jit_file_resolve'),
|
||||
);
|
||||
await createEmptyDir(path.join(rootDir, '.git'));
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
|
||||
|
||||
// Create the target file so fs.stat can identify it as a file
|
||||
@@ -1189,6 +1223,7 @@ included directory memory
|
||||
const rootDir = await createEmptyDir(
|
||||
path.join(testRootDir, 'jit_nonexistent'),
|
||||
);
|
||||
await createEmptyDir(path.join(rootDir, '.git'));
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
|
||||
|
||||
// Target file does NOT exist (e.g. write_file creating a new file)
|
||||
@@ -1209,6 +1244,31 @@ included directory memory
|
||||
expect(result.files[0].path).toBe(subDirMemory);
|
||||
expect(result.files[0].content).toBe('Rules for new files');
|
||||
});
|
||||
|
||||
it('should fall back to trusted root as ceiling when no git root exists', async () => {
|
||||
const rootDir = await createEmptyDir(
|
||||
path.join(testRootDir, 'jit_no_git'),
|
||||
);
|
||||
// No .git directory created — ceiling falls back to trusted root
|
||||
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
|
||||
const targetFile = path.join(subDir, 'target.txt');
|
||||
|
||||
const subDirMemory = await createTestFile(
|
||||
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Content without git',
|
||||
);
|
||||
|
||||
const result = await loadJitSubdirectoryMemory(
|
||||
targetFile,
|
||||
[rootDir],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// subDir is within the trusted root, so its GEMINI.md is found
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].path).toBe(subDirMemory);
|
||||
expect(result.files[0].content).toBe('Content without git');
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshServerHierarchicalMemory should refresh memory and update config', async () => {
|
||||
|
||||
@@ -151,10 +151,10 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
|
||||
while (true) {
|
||||
const gitPath = path.join(currentDir, '.git');
|
||||
try {
|
||||
const stats = await fs.lstat(gitPath);
|
||||
if (stats.isDirectory()) {
|
||||
return currentDir;
|
||||
}
|
||||
// Check for existence only — .git can be a directory (normal repos)
|
||||
// or a file (submodules / worktrees).
|
||||
await fs.access(gitPath);
|
||||
return currentDir;
|
||||
} catch (error: unknown) {
|
||||
// Don't log ENOENT errors as they're expected when .git doesn't exist
|
||||
// Also don't log errors in test environments, which often have mocked fs
|
||||
@@ -175,11 +175,11 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const fsError = error as { code: string; message: string };
|
||||
logger.warn(
|
||||
`Error checking for .git directory at ${gitPath}: ${fsError.message}`,
|
||||
`Error checking for .git at ${gitPath}: ${fsError.message}`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`,
|
||||
`Non-standard error checking for .git at ${gitPath}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -492,12 +492,17 @@ export async function getEnvironmentMemoryPaths(
|
||||
// Trusted Roots Upward Traversal (Parallelized)
|
||||
const traversalPromises = trustedRoots.map(async (root) => {
|
||||
const resolvedRoot = normalizePath(root);
|
||||
const gitRoot = await findProjectRoot(resolvedRoot);
|
||||
const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot;
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:',
|
||||
resolvedRoot,
|
||||
'(Stopping exactly here)',
|
||||
'(Stopping at',
|
||||
gitRoot
|
||||
? `git root: ${ceiling})`
|
||||
: `trusted root: ${ceiling} — no git root found)`,
|
||||
);
|
||||
return findUpwardGeminiFiles(resolvedRoot, resolvedRoot);
|
||||
return findUpwardGeminiFiles(resolvedRoot, ceiling);
|
||||
});
|
||||
|
||||
const pathArrays = await Promise.all(traversalPromises);
|
||||
@@ -761,10 +766,15 @@ export async function loadJitSubdirectoryMemory(
|
||||
return { files: [], fileIdentities: [] };
|
||||
}
|
||||
|
||||
// Find the git root to use as the traversal ceiling.
|
||||
// If no git root exists, fall back to the trusted root as the ceiling.
|
||||
const gitRoot = await findProjectRoot(bestRoot);
|
||||
const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot;
|
||||
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Loading JIT memory for',
|
||||
resolvedTarget,
|
||||
`(Trusted root: ${bestRoot})`,
|
||||
`(Trusted root: ${bestRoot}, Ceiling: ${resolvedCeiling}${gitRoot ? ' [git root]' : ' [trusted root, no git]'})`,
|
||||
);
|
||||
|
||||
// Resolve the target to a directory before traversing upward.
|
||||
@@ -783,8 +793,8 @@ export async function loadJitSubdirectoryMemory(
|
||||
startDir = normalizePath(path.dirname(resolvedTarget));
|
||||
}
|
||||
|
||||
// Traverse from the resolved directory up to the trusted root
|
||||
const potentialPaths = await findUpwardGeminiFiles(startDir, bestRoot);
|
||||
// Traverse from the resolved directory up to the ceiling
|
||||
const potentialPaths = await findUpwardGeminiFiles(startDir, resolvedCeiling);
|
||||
|
||||
if (potentialPaths.length === 0) {
|
||||
return { files: [], fileIdentities: [] };
|
||||
|
||||
@@ -119,8 +119,10 @@ describe('getCommandRoots', () => {
|
||||
expect(getCommandRoots('ls -l')).toEqual(['ls']);
|
||||
});
|
||||
|
||||
it('should handle paths and return the binary name', () => {
|
||||
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']);
|
||||
it('should handle paths and return the full path', () => {
|
||||
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([
|
||||
'/usr/local/bin/node',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array for an empty string', () => {
|
||||
|
||||
@@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string {
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.split(/[\\/]/).pop() ?? trimmed;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function extractNameFromNode(node: Node): string | null {
|
||||
|
||||
@@ -37,9 +37,10 @@ export function determineSurface(): string {
|
||||
return ide.name;
|
||||
}
|
||||
|
||||
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it.
|
||||
// This prevents generic terminals from being misidentified as VSCode.
|
||||
if (process.env['TERM_PROGRAM'] === 'vscode') {
|
||||
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM or VSCODE_PID confirms it.
|
||||
// This prevents generic terminals from being misidentified as VSCode, while still detecting
|
||||
// background processes spawned by the VS Code extension host (like a2a-server).
|
||||
if (process.env['TERM_PROGRAM'] === 'vscode' || process.env['VSCODE_PID']) {
|
||||
return ide.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.35.0-nightly.20260313.bb060d7a9",
|
||||
"version": "0.35.0",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -1971,8 +1971,8 @@
|
||||
"enableAgents": {
|
||||
"title": "Enable Agents",
|
||||
"description": "Enable local and remote subagents.",
|
||||
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensionManagement": {
|
||||
|
||||
Reference in New Issue
Block a user