mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -07:00
Support for Built-in Agent Skills (#16045)
This commit is contained in:
@@ -4,26 +4,27 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { loadSettings } from './settings.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof import('node:os')>();
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...mockedOs,
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @google/gemini-cli-core
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -34,92 +35,130 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe('ExtensionManager skills validation', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'),
|
||||
);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn().mockResolvedValue(''),
|
||||
settings: loadSettings(tempWorkspaceDir).merged,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: {
|
||||
telemetry: { enabled: false },
|
||||
trustedFolders: [tempDir],
|
||||
} as unknown as Settings,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
// Create a source extension
|
||||
const sourceDir = path.join(tempDir, 'source-ext');
|
||||
createExtension({
|
||||
extensionsDir: sourceDir, // createExtension appends name
|
||||
name: 'skills-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, 'skills-ext'),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, 'skills-ext');
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
// Add invalid skills content
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.name).toBe('skills-ext');
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'load-skills-ext',
|
||||
// 1. Create a source extension
|
||||
const sourceDir = path.join(tempDir, 'source-ext-load');
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: 'skills-ext-load',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtPath = path.join(sourceDir, 'skills-ext-load');
|
||||
|
||||
const skillsDir = path.join(extDir, 'skills');
|
||||
// Add invalid skills content
|
||||
const skillsDir = path.join(sourceExtPath, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
// 2. Install it to ensure correct disk state
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: sourceExtPath,
|
||||
});
|
||||
|
||||
// Clear the spy
|
||||
vi.mocked(debugLogger.debug).mockClear();
|
||||
|
||||
// 3. Create a fresh ExtensionManager to force loading from disk
|
||||
const newExtensionManager = new ExtensionManager({
|
||||
settings: {
|
||||
telemetry: { enabled: false },
|
||||
trustedFolders: [tempDir],
|
||||
} as unknown as Settings,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
|
||||
// 4. Load extensions
|
||||
await newExtensionManager.loadExtensions();
|
||||
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'The directory is not empty but no valid skills were discovered',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed if skills are correctly loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
const sourceDir = path.join(tempDir, 'source-ext-good');
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: 'good-skills-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, 'good-skills-ext'),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, 'good-skills-ext');
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'test-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -128,15 +167,13 @@ describe('ExtensionManager skills validation', () => {
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.skills).toHaveLength(1);
|
||||
expect(extension.skills![0].name).toBe('test-skill');
|
||||
// It might be called for other reasons during startup, but shouldn't be called for our skills loading success
|
||||
// Actually, it shouldn't be called with our warning message
|
||||
expect(extension.name).toBe('good-skills-ext');
|
||||
expect(debugLogger.debug).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user