mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 03:54:43 -07:00
Final Changes for stable release (#8105)
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com> Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> Co-authored-by: christine betts <chrstn@uw.edu> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Bryan Morgan <bryanmorgan@google.com> Co-authored-by: anthony bushong <agmsb@users.noreply.github.com> Co-authored-by: Shreya Keshive <skeshive@gmail.com> Co-authored-by: Taylor Mullen <ntaylormullen@google.com> Co-authored-by: Arya Gummadi <aryagummadi@google.com> Co-authored-by: Sandy Tao <sandytao520@icloud.com> Co-authored-by: Pascal Birchler <pascalb@google.com> Co-authored-by: Victor May <mayvic@google.com> Co-authored-by: silvio junior <silviojr.dcc@gmail.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.4.0-preview",
|
||||
"version": "0.4.0-preview.2",
|
||||
"private": true,
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.4.0-preview",
|
||||
"version": "0.4.0-preview.2",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { installCommand, handleInstall } from './install.js';
|
||||
import { installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import * as extension from '../../config/extension.js';
|
||||
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
installExtension: vi.fn(),
|
||||
@@ -17,33 +16,14 @@ describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Either --source or --path must be provided.',
|
||||
'Either source or --path must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if both git source and local path are provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() =>
|
||||
validationParser.parse('install --source some-url --path /some/path'),
|
||||
validationParser.parse('install some-url --path /some/path'),
|
||||
).toThrow('Arguments source and path are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensions install with org/repo', () => {
|
||||
it('should call installExtension with the correct git URL', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const installExtensionSpy = vi
|
||||
.spyOn(extension, 'installExtension')
|
||||
.mockResolvedValue('test-extension');
|
||||
|
||||
await handleInstall({ source: 'test-org/test-repo' });
|
||||
|
||||
expect(installExtensionSpy).toHaveBeenCalledWith({
|
||||
source: 'https://github.com/test-org/test-repo.git',
|
||||
type: 'git',
|
||||
});
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@ interface InstallArgs {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const ORG_REPO_REGEX = /^[a-zA-Z0-9-]+\/[\w.-]+$/;
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
@@ -34,15 +32,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||
source,
|
||||
type: 'git',
|
||||
};
|
||||
} else if (ORG_REPO_REGEX.test(source)) {
|
||||
installMetadata = {
|
||||
source: `https://github.com/${source}.git`,
|
||||
type: 'git',
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`The source "${source}" is not a valid URL or "org/repo" format.`,
|
||||
);
|
||||
throw new Error(`The source "${source}" is not a valid URL format.`);
|
||||
}
|
||||
} else if (args.path) {
|
||||
installMetadata = {
|
||||
@@ -54,10 +45,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
}
|
||||
|
||||
const extensionName = await installExtension(installMetadata);
|
||||
console.log(
|
||||
`Extension "${extensionName}" installed successfully and enabled.`,
|
||||
);
|
||||
const name = await installExtension(installMetadata);
|
||||
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -65,13 +54,12 @@ export async function handleInstall(args: InstallArgs) {
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install [--source | --path ]',
|
||||
describe:
|
||||
'Installs an extension from a git repository (URL or "org/repo") or a local path.',
|
||||
command: 'install [source]',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('source', {
|
||||
describe: 'The git URL or "org/repo" of the extension to install.',
|
||||
.positional('source', {
|
||||
describe: 'The github URL of the extension to install.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('path', {
|
||||
@@ -81,7 +69,7 @@ export const installCommand: CommandModule = {
|
||||
.conflicts('source', 'path')
|
||||
.check((argv) => {
|
||||
if (!argv.source && !argv.path) {
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
throw new Error('Either source or --path must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
|
||||
@@ -305,7 +305,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand);
|
||||
|
||||
if (settings?.experimental?.extensionManagement ?? false) {
|
||||
if (settings?.experimental?.extensionManagement ?? true) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
GEMINI_DIR,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
ClearcutLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope, loadSettings } from './settings.js';
|
||||
@@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mockLogExtensionInstallEvent = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
ClearcutLogger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
})),
|
||||
},
|
||||
Config: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
@@ -63,59 +81,36 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
describe('loadExtensions', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
let tempHomeDir: string;
|
||||
let workspaceExtensionsDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
|
||||
workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores extensions in untrusted workspaces', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(false);
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: true,
|
||||
});
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extensionDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
@@ -123,70 +118,70 @@ describe('loadExtensions', () => {
|
||||
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: true,
|
||||
});
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext2',
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
const ext2 = extensions.find((e) => e.config.name === 'ext2');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'),
|
||||
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
|
||||
]);
|
||||
expect(ext2?.contextFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load context file path from the extension config', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: false,
|
||||
contextFileName: 'my-context-file.md',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
|
||||
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out disabled extensions', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext2',
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR);
|
||||
const settingsDir = path.join(tempHomeDir, GEMINI_DIR);
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'settings.json'),
|
||||
JSON.stringify({ extensions: { disabled: ['ext1'] } }),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempWorkspaceDir,
|
||||
tempHomeDir,
|
||||
).filter((e) => e.isActive);
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].name).toBe('ext2');
|
||||
@@ -194,7 +189,7 @@ describe('loadExtensions', () => {
|
||||
|
||||
it('should hydrate variables', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
addContextFile: false,
|
||||
@@ -206,11 +201,11 @@ describe('loadExtensions', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
const loadedConfig = extensions[0].config;
|
||||
const expectedCwd = path.join(
|
||||
workspaceExtensionsDir,
|
||||
userExtensionsDir,
|
||||
'test-extension',
|
||||
'server',
|
||||
);
|
||||
@@ -218,6 +213,9 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
@@ -231,7 +229,7 @@ describe('loadExtensions', () => {
|
||||
type: 'link',
|
||||
});
|
||||
expect(extensionName).toEqual('my-linked-extension');
|
||||
const extensions = loadExtensions(tempHomeDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
||||
const linkedExt = extensions[0];
|
||||
@@ -252,13 +250,13 @@ describe('loadExtensions', () => {
|
||||
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
||||
|
||||
try {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
const userExtensionsDir = path.join(
|
||||
tempHomeDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extDir);
|
||||
|
||||
// Write config to a separate file for clarity and good practices
|
||||
@@ -280,7 +278,7 @@ describe('loadExtensions', () => {
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -302,13 +300,10 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
|
||||
it('should handle missing environment variables gracefully', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extDir);
|
||||
|
||||
const extensionConfig = {
|
||||
@@ -331,7 +326,7 @@ describe('loadExtensions', () => {
|
||||
JSON.stringify(extensionConfig),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -542,6 +537,19 @@ describe('installExtension', () => {
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should log to clearcut on successful install', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
|
||||
const logger = ClearcutLogger.getInstance({} as Config);
|
||||
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallExtension', () => {
|
||||
@@ -592,7 +600,7 @@ describe('uninstallExtension', () => {
|
||||
await uninstallExtension('my-local-extension');
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(loadExtensions(tempHomeDir)).toHaveLength(1);
|
||||
expect(loadExtensions()).toHaveLength(1);
|
||||
expect(fs.existsSync(otherExtDir)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -675,7 +683,7 @@ describe('performWorkspaceExtensionMigration', () => {
|
||||
});
|
||||
|
||||
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]);
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toEqual([]);
|
||||
});
|
||||
@@ -703,7 +711,7 @@ describe('performWorkspaceExtensionMigration', () => {
|
||||
|
||||
const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
|
||||
const userExt1Path = path.join(userExtensionsDir, 'ext1');
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
|
||||
@@ -912,7 +920,7 @@ describe('enableExtension', () => {
|
||||
});
|
||||
|
||||
const getActiveExtensions = (): GeminiCLIExtension[] => {
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
|
||||
@@ -8,7 +8,13 @@ import type {
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
ClearcutLogger,
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js';
|
||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
@@ -119,7 +126,8 @@ export function loadExtensions(
|
||||
|
||||
if (
|
||||
(isWorkspaceTrusted(settings) ?? true) &&
|
||||
!settings.experimental?.extensionManagement
|
||||
// Default management setting to true
|
||||
!(settings.experimental?.extensionManagement ?? true)
|
||||
) {
|
||||
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
|
||||
}
|
||||
@@ -345,83 +353,120 @@ export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
// Convert relative paths to absolute paths for the metadata file.
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let localSourcePath: string;
|
||||
let tempDir: string | undefined;
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
const config = new Config({
|
||||
sessionId: randomUUID(),
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model: '',
|
||||
debugMode: false,
|
||||
});
|
||||
const logger = ClearcutLogger.getInstance(config);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
|
||||
try {
|
||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
return newExtensionConfig!.name;
|
||||
} catch (error) {
|
||||
// Attempt to load config from the source path even if installation fails
|
||||
// to get the name and version for logging.
|
||||
if (!newExtensionConfig && localSourcePath) {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
}
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return newExtensionName;
|
||||
}
|
||||
|
||||
async function loadExtensionConfig(
|
||||
export async function loadExtensionConfig(
|
||||
extensionDir: string,
|
||||
): Promise<ExtensionConfig | null> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
@@ -521,7 +521,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore folderTrust from workspace settings', () => {
|
||||
it('should use folderTrust from workspace settings when trusted', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
security: {
|
||||
@@ -533,7 +533,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const workspaceSettingsContent = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false, // This should be ignored
|
||||
enabled: false, // This should be used
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -554,7 +554,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
|
||||
});
|
||||
|
||||
it('should use system folderTrust over user setting', () => {
|
||||
|
||||
@@ -76,7 +76,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensionManagement: 'advanced.extensionManagement',
|
||||
extensionManagement: 'experimental.extensionManagement',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
@@ -329,18 +329,6 @@ function mergeSettings(
|
||||
): Settings {
|
||||
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
||||
|
||||
// folderTrust is not supported at workspace level.
|
||||
const { security, ...restOfWorkspace } = safeWorkspace;
|
||||
const safeWorkspaceWithoutFolderTrust = security
|
||||
? {
|
||||
...restOfWorkspace,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
security: (({ folderTrust, ...rest }) => rest)(security),
|
||||
}
|
||||
: {
|
||||
...restOfWorkspace,
|
||||
};
|
||||
|
||||
// Settings are merged with the following precedence (last one wins for
|
||||
// single values):
|
||||
// 1. System Defaults
|
||||
@@ -352,7 +340,7 @@ function mergeSettings(
|
||||
{}, // Start with an empty object
|
||||
systemDefaults,
|
||||
user,
|
||||
safeWorkspaceWithoutFolderTrust,
|
||||
safeWorkspace,
|
||||
system,
|
||||
) as Settings;
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@ export const SETTINGS_SCHEMA = {
|
||||
label: 'Extension Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enable extension management features.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,8 @@ export function useWorkspaceMigration(settings: LoadedSettings) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.merged.experimental?.extensionManagement) {
|
||||
// Default to true if not set.
|
||||
if (!(settings.merged.experimental?.extensionManagement ?? true)) {
|
||||
return;
|
||||
}
|
||||
const cwd = process.cwd();
|
||||
|
||||
@@ -16,7 +16,9 @@ export { logIdeConnection } from './src/telemetry/loggers.js';
|
||||
export {
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
ExtensionInstallEvent,
|
||||
} from './src/telemetry/types.js';
|
||||
export { getIdeTrust } from './src/utils/ide-trust.js';
|
||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||
export * from './src/utils/pathReader.js';
|
||||
export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.4.0-preview",
|
||||
"version": "0.4.0-preview.2",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -282,7 +282,7 @@ export class Config {
|
||||
private readonly useRipgrep: boolean;
|
||||
private readonly shouldUseNodePtyShell: boolean;
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly extensionManagement: boolean;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private initialized: boolean = false;
|
||||
readonly storage: Storage;
|
||||
@@ -359,8 +359,8 @@ export class Config {
|
||||
this.useRipgrep = params.useRipgrep ?? false;
|
||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
||||
this.useSmartEdit = params.useSmartEdit ?? true;
|
||||
this.extensionManagement = params.extensionManagement ?? false;
|
||||
this.useSmartEdit = params.useSmartEdit ?? false;
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.storage = new Storage(this.targetDir);
|
||||
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
|
||||
@@ -416,9 +416,9 @@ describe('GeminiChat', () => {
|
||||
expect(modelTurn?.parts![0]!.functionCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should succeed if the stream ends with an empty part but has a valid finishReason', async () => {
|
||||
// 1. Mock a stream that ends with an invalid part but has a 'STOP' finish reason.
|
||||
const streamWithValidFinish = (async function* () {
|
||||
it('should fail if the stream ends with an empty part and has no finishReason', async () => {
|
||||
// 1. Mock a stream that ends with an invalid part and has no finish reason.
|
||||
const streamWithNoFinish = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
@@ -429,7 +429,7 @@ describe('GeminiChat', () => {
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
// This second chunk is invalid, but the finishReason should save it from retrying.
|
||||
// This second chunk is invalid and has no finishReason, so it should fail.
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
@@ -437,6 +437,50 @@ describe('GeminiChat', () => {
|
||||
role: 'model',
|
||||
parts: [{ text: '' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
|
||||
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
|
||||
streamWithNoFinish,
|
||||
);
|
||||
|
||||
// 2. Action & Assert: The stream should fail because there's no finish reason.
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ message: 'test message' },
|
||||
'prompt-id-no-finish-empty-end',
|
||||
);
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume stream */
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow(EmptyStreamError);
|
||||
});
|
||||
|
||||
it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => {
|
||||
// 1. Mock a stream that sends a valid chunk, then an invalid one, but has a finish reason.
|
||||
const streamWithInvalidEnd = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Initial valid content...' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
// This second chunk is invalid, but the response has a finishReason.
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: '' }], // Invalid part
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
@@ -444,14 +488,13 @@ describe('GeminiChat', () => {
|
||||
})();
|
||||
|
||||
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
|
||||
streamWithValidFinish,
|
||||
streamWithInvalidEnd,
|
||||
);
|
||||
|
||||
// 2. Action & Assert: The stream should complete successfully because the valid
|
||||
// finishReason overrides the invalid final chunk.
|
||||
// 2. Action & Assert: The stream should complete without throwing an error.
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ message: 'test message' },
|
||||
'prompt-id-valid-finish-empty-end',
|
||||
'prompt-id-valid-then-invalid-end',
|
||||
);
|
||||
await expect(
|
||||
(async () => {
|
||||
@@ -461,12 +504,12 @@ describe('GeminiChat', () => {
|
||||
})(),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// 3. Verify history was recorded correctly
|
||||
// 3. Verify history was recorded correctly with only the valid part.
|
||||
const history = chat.getHistory();
|
||||
expect(history.length).toBe(2);
|
||||
expect(history.length).toBe(2); // user turn + model turn
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded
|
||||
expect(modelTurn?.parts![0]!.text).toBe('Initial content...');
|
||||
expect(modelTurn?.parts?.length).toBe(1);
|
||||
expect(modelTurn?.parts![0]!.text).toBe('Initial valid content...');
|
||||
});
|
||||
it('should not consolidate text into a part that also contains a functionCall', async () => {
|
||||
// 1. Mock the API to stream a malformed part followed by a valid text part.
|
||||
@@ -542,7 +585,10 @@ describe('GeminiChat', () => {
|
||||
// as the important part is consolidating what comes after.
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: ' World!' }] } },
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: ' World!' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
@@ -645,6 +691,7 @@ describe('GeminiChat', () => {
|
||||
{ text: 'This is the visible text that should not be lost.' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
@@ -705,7 +752,10 @@ describe('GeminiChat', () => {
|
||||
const emptyStreamResponse = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ thought: true }] } },
|
||||
{
|
||||
content: { role: 'model', parts: [{ thought: true }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
@@ -975,7 +1025,12 @@ describe('GeminiChat', () => {
|
||||
// Second attempt (the retry): A minimal valid stream.
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: 'Success' }] } }],
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
@@ -1012,7 +1067,10 @@ describe('GeminiChat', () => {
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'Successful response' }] } },
|
||||
{
|
||||
content: { parts: [{ text: 'Successful response' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
@@ -1123,7 +1181,12 @@ describe('GeminiChat', () => {
|
||||
// Second attempt succeeds
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: 'Second answer' }] } }],
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Second answer' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
@@ -1272,6 +1335,7 @@ describe('GeminiChat', () => {
|
||||
content: {
|
||||
parts: [{ text: 'Successful response after empty' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
@@ -1333,13 +1397,23 @@ describe('GeminiChat', () => {
|
||||
} as unknown as GenerateContentResponse;
|
||||
await firstStreamContinuePromise; // Pause the stream
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: ' part 2' }] } }],
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: ' part 2' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
|
||||
const secondStreamGenerator = (async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: 'second response' }] } }],
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'second response' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
|
||||
@@ -1424,6 +1498,7 @@ describe('GeminiChat', () => {
|
||||
content: {
|
||||
parts: [{ text: 'Successful final response' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
|
||||
@@ -612,23 +612,18 @@ export class GeminiChat {
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
const modelResponseParts: Part[] = [];
|
||||
let hasReceivedAnyChunk = false;
|
||||
let hasReceivedValidChunk = false;
|
||||
let hasToolCall = false;
|
||||
let lastChunk: GenerateContentResponse | null = null;
|
||||
|
||||
let isStreamInvalid = false;
|
||||
let firstInvalidChunkEncountered = false;
|
||||
let validChunkAfterInvalidEncountered = false;
|
||||
let lastChunkIsInvalid = false;
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
hasReceivedAnyChunk = true;
|
||||
lastChunk = chunk;
|
||||
|
||||
if (isValidResponse(chunk)) {
|
||||
if (firstInvalidChunkEncountered) {
|
||||
// A valid chunk appeared *after* an invalid one.
|
||||
validChunkAfterInvalidEncountered = true;
|
||||
}
|
||||
|
||||
hasReceivedValidChunk = true;
|
||||
lastChunkIsInvalid = false;
|
||||
const content = chunk.candidates?.[0]?.content;
|
||||
if (content?.parts) {
|
||||
if (content.parts.some((part) => part.thought)) {
|
||||
@@ -640,14 +635,16 @@ export class GeminiChat {
|
||||
}
|
||||
// Always add parts - thoughts will be filtered out later in recordHistory
|
||||
modelResponseParts.push(...content.parts);
|
||||
if (content.parts.some((part) => part.functionCall)) {
|
||||
hasToolCall = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logInvalidChunk(
|
||||
this.config,
|
||||
new InvalidChunkEvent('Invalid chunk received from stream.'),
|
||||
);
|
||||
isStreamInvalid = true;
|
||||
firstInvalidChunkEncountered = true;
|
||||
lastChunkIsInvalid = true;
|
||||
}
|
||||
|
||||
// Record token usage if this chunk has usageMetadata
|
||||
@@ -662,27 +659,24 @@ export class GeminiChat {
|
||||
throw new EmptyStreamError('Model stream completed without any chunks.');
|
||||
}
|
||||
|
||||
// --- FIX: The entire validation block was restructured for clarity and correctness ---
|
||||
// Only apply complex validation if an invalid chunk was actually found.
|
||||
if (isStreamInvalid) {
|
||||
// Fail immediately if an invalid chunk was not the absolute last chunk.
|
||||
if (validChunkAfterInvalidEncountered) {
|
||||
throw new EmptyStreamError(
|
||||
'Model stream had invalid intermediate chunks without a tool call.',
|
||||
);
|
||||
}
|
||||
const hasFinishReason = lastChunk?.candidates?.some(
|
||||
(candidate) => candidate.finishReason,
|
||||
);
|
||||
|
||||
if (!hasToolCall) {
|
||||
// If the *only* invalid part was the last chunk, we still check its finish reason.
|
||||
const finishReason = lastChunk?.candidates?.[0]?.finishReason;
|
||||
const isSuccessfulFinish =
|
||||
finishReason === 'STOP' || finishReason === 'MAX_TOKENS';
|
||||
if (!isSuccessfulFinish) {
|
||||
throw new EmptyStreamError(
|
||||
'Model stream ended with an invalid chunk and a failed finish reason.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Stream validation logic: A stream is considered successful if:
|
||||
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
|
||||
// 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks)
|
||||
//
|
||||
// We throw an error only when there's no tool call AND:
|
||||
// - No finish reason, OR
|
||||
// - Last chunk is invalid after receiving valid content
|
||||
if (
|
||||
!hasToolCall &&
|
||||
(!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk))
|
||||
) {
|
||||
throw new EmptyStreamError(
|
||||
'Model stream ended with an invalid chunk or missing finish reason.',
|
||||
);
|
||||
}
|
||||
|
||||
// Record model response text from the collected parts
|
||||
|
||||
@@ -24,39 +24,44 @@ async function getProcessInfo(pid: number): Promise<{
|
||||
name: string;
|
||||
command: string;
|
||||
}> {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') {
|
||||
const powershellCommand = [
|
||||
'$p = Get-CimInstance Win32_Process',
|
||||
`-Filter 'ProcessId=${pid}'`,
|
||||
'-ErrorAction SilentlyContinue;',
|
||||
'if ($p) {',
|
||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
||||
'| ConvertTo-Json',
|
||||
'}',
|
||||
].join(' ');
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
|
||||
const output = stdout.trim();
|
||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
||||
const {
|
||||
Name = '',
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return { parentPid: ParentProcessId, name: Name, command: CommandLine };
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
try {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') {
|
||||
const powershellCommand = [
|
||||
'$p = Get-CimInstance Win32_Process',
|
||||
`-Filter 'ProcessId=${pid}'`,
|
||||
'-ErrorAction SilentlyContinue;',
|
||||
'if ($p) {',
|
||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
||||
'| ConvertTo-Json',
|
||||
'}',
|
||||
].join(' ');
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
|
||||
const output = stdout.trim();
|
||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
||||
const {
|
||||
Name = '',
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return { parentPid: ParentProcessId, name: Name, command: CommandLine };
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
console.debug(`Failed to get process info for pid ${pid}:`, _e);
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +174,6 @@ async function getIdeProcessInfoForWindows(): Promise<{
|
||||
* top-level ancestor process ID and command as a fallback.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
* @throws Will throw an error if the underlying shell commands fail.
|
||||
*/
|
||||
export async function getIdeProcessInfo(): Promise<{
|
||||
pid: number;
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('ClearcutLogger', () => {
|
||||
const cli_version = CLI_VERSION;
|
||||
const git_commit_hash = GIT_COMMIT_INFO;
|
||||
const prompt_id = 'my-prompt-123';
|
||||
const user_settings = safeJsonStringify([{ smart_edit_enabled: true }]);
|
||||
const user_settings = safeJsonStringify([{ smart_edit_enabled: false }]);
|
||||
|
||||
// Setup logger with expected values
|
||||
const { logger, loggerConfig } = setup({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
ContentRetryEvent,
|
||||
ContentRetryFailureEvent,
|
||||
ExtensionInstallEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
@@ -55,6 +56,7 @@ export enum EventNames {
|
||||
INVALID_CHUNK = 'invalid_chunk',
|
||||
CONTENT_RETRY = 'content_retry',
|
||||
CONTENT_RETRY_FAILURE = 'content_retry_failure',
|
||||
EXTENSION_INSTALL = 'extension_install',
|
||||
}
|
||||
|
||||
export interface LogResponse {
|
||||
@@ -825,6 +827,32 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logExtensionInstallEvent(event: ExtensionInstallEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME,
|
||||
value: event.extension_name,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION,
|
||||
value: event.extension_version,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE,
|
||||
value: event.extension_source,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_INSTALL_STATUS,
|
||||
value: event.status,
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.EXTENSION_INSTALL, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds default fields to data, and returns a new data array. This fields
|
||||
* should exist on all log events.
|
||||
|
||||
@@ -331,4 +331,20 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs the current nodejs version
|
||||
GEMINI_CLI_NODE_VERSION = 83,
|
||||
|
||||
// ==========================================================================
|
||||
// Extension Install Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the name of the extension.
|
||||
GEMINI_CLI_EXTENSION_NAME = 85,
|
||||
|
||||
// Logs the version of the extension.
|
||||
GEMINI_CLI_EXTENSION_VERSION = 86,
|
||||
|
||||
// Logs the source of the extension.
|
||||
GEMINI_CLI_EXTENSION_SOURCE = 87,
|
||||
|
||||
// Logs the status of the extension install.
|
||||
GEMINI_CLI_EXTENSION_INSTALL_STATUS = 88,
|
||||
}
|
||||
|
||||
@@ -517,4 +517,28 @@ export type TelemetryEvent =
|
||||
| FileOperationEvent
|
||||
| InvalidChunkEvent
|
||||
| ContentRetryEvent
|
||||
| ContentRetryFailureEvent;
|
||||
| ContentRetryFailureEvent
|
||||
| ExtensionInstallEvent;
|
||||
|
||||
export class ExtensionInstallEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_install';
|
||||
'event.timestamp': string;
|
||||
extension_name: string;
|
||||
extension_version: string;
|
||||
extension_source: string;
|
||||
status: 'success' | 'error';
|
||||
|
||||
constructor(
|
||||
extension_name: string,
|
||||
extension_version: string,
|
||||
extension_source: string,
|
||||
status: 'success' | 'error',
|
||||
) {
|
||||
this['event.name'] = 'extension_install';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.extension_name = extension_name;
|
||||
this.extension_version = extension_version;
|
||||
this.extension_source = extension_source;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.4.0-preview",
|
||||
"version": "0.4.0-preview.2",
|
||||
"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.4.0-preview",
|
||||
"version": "0.4.0-preview.2",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user