mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -07:00
Agent Skills: Implement /skills reload (#15865)
This commit is contained in:
@@ -726,6 +726,12 @@ export async function loadCliConfig(
|
|||||||
hooks: settings.hooks || {},
|
hooks: settings.hooks || {},
|
||||||
projectHooks: projectHooks || {},
|
projectHooks: projectHooks || {},
|
||||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||||
|
onReload: async () => {
|
||||||
|
const refreshedSettings = loadSettings(cwd);
|
||||||
|
return {
|
||||||
|
disabledSkills: refreshedSettings.merged.skills?.disabled,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ vi.mock('./extension.js');
|
|||||||
|
|
||||||
const mockCoreEvents = vi.hoisted(() => ({
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
|
emitSettingsChanged: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ export class LoadedSettings {
|
|||||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||||
this._merged = this.computeMergedSettings();
|
this._merged = this.computeMergedSettings();
|
||||||
saveSettings(settingsFile);
|
saveSettings(settingsFile);
|
||||||
|
coreEvents.emitSettingsChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||||
|
const [settingsNonce, setSettingsNonce] = useState(0);
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||||
@@ -368,6 +369,17 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
};
|
};
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSettingsChanged = () => {
|
||||||
|
setSettingsNonce((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||||
|
return () => {
|
||||||
|
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
|
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
|
||||||
useConsoleMessages();
|
useConsoleMessages();
|
||||||
|
|
||||||
@@ -1546,6 +1558,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
|
settingsNonce,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
@@ -1638,6 +1651,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
config,
|
config,
|
||||||
|
settingsNonce,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { skillsCommand } from './skillsCommand.js';
|
import { skillsCommand } from './skillsCommand.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import type { CommandContext } from './types.js';
|
import type { CommandContext } from './types.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config, SkillDefinition } from '@google/gemini-cli-core';
|
||||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
describe('skillsCommand', () => {
|
describe('skillsCommand', () => {
|
||||||
let context: CommandContext;
|
let context: CommandContext;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const skills = [
|
const skills = [
|
||||||
{
|
{
|
||||||
name: 'skill1',
|
name: 'skill1',
|
||||||
@@ -35,6 +36,7 @@ describe('skillsCommand', () => {
|
|||||||
config: {
|
config: {
|
||||||
getSkillManager: vi.fn().mockReturnValue({
|
getSkillManager: vi.fn().mockReturnValue({
|
||||||
getAllSkills: vi.fn().mockReturnValue(skills),
|
getAllSkills: vi.fn().mockReturnValue(skills),
|
||||||
|
getSkills: vi.fn().mockReturnValue(skills),
|
||||||
getSkill: vi
|
getSkill: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
@@ -51,6 +53,11 @@ describe('skillsCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should add a SKILLS_LIST item to UI with descriptions by default', async () => {
|
it('should add a SKILLS_LIST item to UI with descriptions by default', async () => {
|
||||||
await skillsCommand.action!(context, '');
|
await skillsCommand.action!(context, '');
|
||||||
|
|
||||||
@@ -187,6 +194,170 @@ describe('skillsCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reload', () => {
|
||||||
|
it('should reload skills successfully and show success message', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
// Make reload take some time so timer can fire
|
||||||
|
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
});
|
||||||
|
// @ts-expect-error Mocking reloadSkills
|
||||||
|
context.services.config.reloadSkills = reloadSkillsMock;
|
||||||
|
|
||||||
|
const actionPromise = reloadCmd.action!(context, '');
|
||||||
|
|
||||||
|
// Initially, no pending item (flicker prevention)
|
||||||
|
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Fast forward 100ms to trigger the pending item
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(context.ui.setPendingItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Reloading agent skills...',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast forward another 100ms (reload complete), but pending item should stay
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null);
|
||||||
|
|
||||||
|
// Fast forward to reach 500ms total
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
await actionPromise;
|
||||||
|
|
||||||
|
expect(reloadSkillsMock).toHaveBeenCalled();
|
||||||
|
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Agent skills reloaded successfully.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show new skills count after reload', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||||
|
const skillManager = context.services.config!.getSkillManager();
|
||||||
|
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||||
|
{ name: 'skill1' },
|
||||||
|
{ name: 'skill2' },
|
||||||
|
{ name: 'skill3' },
|
||||||
|
] as SkillDefinition[]);
|
||||||
|
});
|
||||||
|
// @ts-expect-error Mocking reloadSkills
|
||||||
|
context.services.config.reloadSkills = reloadSkillsMock;
|
||||||
|
|
||||||
|
await reloadCmd.action!(context, '');
|
||||||
|
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Agent skills reloaded successfully. 1 newly available skill.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show removed skills count after reload', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||||
|
const skillManager = context.services.config!.getSkillManager();
|
||||||
|
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||||
|
{ name: 'skill1' },
|
||||||
|
] as SkillDefinition[]);
|
||||||
|
});
|
||||||
|
// @ts-expect-error Mocking reloadSkills
|
||||||
|
context.services.config.reloadSkills = reloadSkillsMock;
|
||||||
|
|
||||||
|
await reloadCmd.action!(context, '');
|
||||||
|
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Agent skills reloaded successfully. 1 skill no longer available.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show both added and removed skills count after reload', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||||
|
const skillManager = context.services.config!.getSkillManager();
|
||||||
|
vi.mocked(skillManager.getSkills).mockReturnValue([
|
||||||
|
{ name: 'skill2' }, // skill1 removed, skill3 added
|
||||||
|
{ name: 'skill3' },
|
||||||
|
] as SkillDefinition[]);
|
||||||
|
});
|
||||||
|
// @ts-expect-error Mocking reloadSkills
|
||||||
|
context.services.config.reloadSkills = reloadSkillsMock;
|
||||||
|
|
||||||
|
await reloadCmd.action!(context, '');
|
||||||
|
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error if configuration is missing', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
context.services.config = null;
|
||||||
|
|
||||||
|
await reloadCmd.action!(context, '');
|
||||||
|
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Could not retrieve configuration.',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error if reload fails', async () => {
|
||||||
|
const reloadCmd = skillsCommand.subCommands!.find(
|
||||||
|
(s) => s.name === 'reload',
|
||||||
|
)!;
|
||||||
|
const error = new Error('Reload failed');
|
||||||
|
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
|
||||||
|
await new Promise((_, reject) => setTimeout(() => reject(error), 200));
|
||||||
|
});
|
||||||
|
// @ts-expect-error Mocking reloadSkills
|
||||||
|
context.services.config.reloadSkills = reloadSkillsMock;
|
||||||
|
|
||||||
|
const actionPromise = reloadCmd.action!(context, '');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await actionPromise;
|
||||||
|
|
||||||
|
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||||
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Failed to reload skills: Reload failed',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('completions', () => {
|
describe('completions', () => {
|
||||||
it('should provide completions for disable (only enabled skills)', async () => {
|
it('should provide completions for disable (only enabled skills)', async () => {
|
||||||
const disableCmd = skillsCommand.subCommands!.find(
|
const disableCmd = skillsCommand.subCommands!.find(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
type SlashCommandActionReturn,
|
type SlashCommandActionReturn,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { MessageType, type HistoryItemSkillsList } from '../types.js';
|
import {
|
||||||
|
MessageType,
|
||||||
|
type HistoryItemSkillsList,
|
||||||
|
type HistoryItemInfo,
|
||||||
|
} from '../types.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
async function listAction(
|
async function listAction(
|
||||||
@@ -104,7 +108,7 @@ async function disableAction(
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`,
|
text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -148,12 +152,107 @@ async function enableAction(
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`,
|
text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reloadAction(
|
||||||
|
context: CommandContext,
|
||||||
|
): Promise<void | SlashCommandActionReturn> {
|
||||||
|
const config = context.services.config;
|
||||||
|
if (!config) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Could not retrieve configuration.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillManager = config.getSkillManager();
|
||||||
|
const beforeNames = new Set(skillManager.getSkills().map((s) => s.name));
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let pendingItemSet = false;
|
||||||
|
const pendingTimeout = setTimeout(() => {
|
||||||
|
context.ui.setPendingItem({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Reloading agent skills...',
|
||||||
|
});
|
||||||
|
pendingItemSet = true;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await config.reloadSkills();
|
||||||
|
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
if (pendingItemSet) {
|
||||||
|
// If we showed the pending item, make sure it stays for at least 500ms
|
||||||
|
// total to avoid a "flicker" where it appears and immediately disappears.
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const minVisibleDuration = 500;
|
||||||
|
if (elapsed < minVisibleDuration) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, minVisibleDuration - elapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.ui.setPendingItem(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterSkills = skillManager.getSkills();
|
||||||
|
const afterNames = new Set(afterSkills.map((s) => s.name));
|
||||||
|
|
||||||
|
const added = afterSkills.filter((s) => !beforeNames.has(s.name));
|
||||||
|
const removedCount = [...beforeNames].filter(
|
||||||
|
(name) => !afterNames.has(name),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let successText = 'Agent skills reloaded successfully.';
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
details.push(
|
||||||
|
`${added.length} newly available skill${added.length > 1 ? 's' : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (removedCount > 0) {
|
||||||
|
details.push(
|
||||||
|
`${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.length > 0) {
|
||||||
|
successText += ` ${details.join(' and ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
text: successText,
|
||||||
|
icon: '✓ ',
|
||||||
|
color: 'green',
|
||||||
|
} as HistoryItemInfo,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
if (pendingItemSet) {
|
||||||
|
context.ui.setPendingItem(null);
|
||||||
|
}
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function disableCompletion(
|
function disableCompletion(
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
partialArg: string,
|
partialArg: string,
|
||||||
@@ -185,7 +284,7 @@ function enableCompletion(
|
|||||||
export const skillsCommand: SlashCommand = {
|
export const skillsCommand: SlashCommand = {
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
description:
|
description:
|
||||||
'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name>]',
|
'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name> | reload]',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: false,
|
||||||
subCommands: [
|
subCommands: [
|
||||||
@@ -210,6 +309,13 @@ export const skillsCommand: SlashCommand = {
|
|||||||
action: enableAction,
|
action: enableAction,
|
||||||
completion: enableCompletion,
|
completion: enableCompletion,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'reload',
|
||||||
|
description:
|
||||||
|
'Reload the list of discovered skills. Usage: /skills reload',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: reloadAction,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
action: listAction,
|
action: listAction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export interface UIState {
|
|||||||
bannerVisible: boolean;
|
bannerVisible: boolean;
|
||||||
customDialog: React.ReactNode | null;
|
customDialog: React.ReactNode | null;
|
||||||
terminalBackgroundColor: TerminalBackgroundColor;
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
|
settingsNonce: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIStateContext = createContext<UIState | null>(null);
|
export const UIStateContext = createContext<UIState | null>(null);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
|
|||||||
import { logRipgrepFallback } from '../telemetry/loggers.js';
|
import { logRipgrepFallback } from '../telemetry/loggers.js';
|
||||||
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
||||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
|
import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js';
|
||||||
|
import type { SkillDefinition } from '../skills/skillLoader.js';
|
||||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_GEMINI_MODEL,
|
DEFAULT_GEMINI_MODEL,
|
||||||
@@ -57,6 +59,7 @@ vi.mock('fs', async (importOriginal) => {
|
|||||||
vi.mock('../tools/tool-registry', () => {
|
vi.mock('../tools/tool-registry', () => {
|
||||||
const ToolRegistryMock = vi.fn();
|
const ToolRegistryMock = vi.fn();
|
||||||
ToolRegistryMock.prototype.registerTool = vi.fn();
|
ToolRegistryMock.prototype.registerTool = vi.fn();
|
||||||
|
ToolRegistryMock.prototype.unregisterTool = vi.fn();
|
||||||
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
|
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
|
||||||
ToolRegistryMock.prototype.sortTools = vi.fn();
|
ToolRegistryMock.prototype.sortTools = vi.fn();
|
||||||
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
||||||
@@ -104,6 +107,7 @@ vi.mock('../core/client.js', () => ({
|
|||||||
GeminiClient: vi.fn().mockImplementation(() => ({
|
GeminiClient: vi.fn().mockImplementation(() => ({
|
||||||
initialize: vi.fn().mockResolvedValue(undefined),
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
stripThoughtsFromHistory: vi.fn(),
|
stripThoughtsFromHistory: vi.fn(),
|
||||||
|
isInitialized: vi.fn().mockReturnValue(false),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1978,4 +1982,104 @@ describe('Config JIT Initialization', () => {
|
|||||||
expect(ContextManager).not.toHaveBeenCalled();
|
expect(ContextManager).not.toHaveBeenCalled();
|
||||||
expect(config.getUserMemory()).toBe('Initial Memory');
|
expect(config.getUserMemory()).toBe('Initial Memory');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reloadSkills', () => {
|
||||||
|
it('should refresh disabledSkills and re-register ActivateSkillTool when skills exist', async () => {
|
||||||
|
const mockOnReload = vi.fn().mockResolvedValue({
|
||||||
|
disabledSkills: ['skill2'],
|
||||||
|
});
|
||||||
|
const params: ConfigParameters = {
|
||||||
|
sessionId: 'test-session',
|
||||||
|
targetDir: '/tmp/test',
|
||||||
|
debugMode: false,
|
||||||
|
model: 'test-model',
|
||||||
|
cwd: '/tmp/test',
|
||||||
|
skillsSupport: true,
|
||||||
|
onReload: mockOnReload,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = new Config(params);
|
||||||
|
await config.initialize();
|
||||||
|
|
||||||
|
const skillManager = config.getSkillManager();
|
||||||
|
const toolRegistry = config.getToolRegistry();
|
||||||
|
|
||||||
|
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(skillManager, 'setDisabledSkills');
|
||||||
|
vi.spyOn(toolRegistry, 'registerTool');
|
||||||
|
vi.spyOn(toolRegistry, 'unregisterTool');
|
||||||
|
|
||||||
|
const mockSkills = [{ name: 'skill1' }];
|
||||||
|
vi.spyOn(skillManager, 'getSkills').mockReturnValue(
|
||||||
|
mockSkills as SkillDefinition[],
|
||||||
|
);
|
||||||
|
|
||||||
|
await config.reloadSkills();
|
||||||
|
|
||||||
|
expect(mockOnReload).toHaveBeenCalled();
|
||||||
|
expect(skillManager.setDisabledSkills).toHaveBeenCalledWith(['skill2']);
|
||||||
|
expect(toolRegistry.registerTool).toHaveBeenCalled();
|
||||||
|
expect(toolRegistry.unregisterTool).not.toHaveBeenCalledWith(
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister ActivateSkillTool when no skills exist after reload', async () => {
|
||||||
|
const params: ConfigParameters = {
|
||||||
|
sessionId: 'test-session',
|
||||||
|
targetDir: '/tmp/test',
|
||||||
|
debugMode: false,
|
||||||
|
model: 'test-model',
|
||||||
|
cwd: '/tmp/test',
|
||||||
|
skillsSupport: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = new Config(params);
|
||||||
|
await config.initialize();
|
||||||
|
|
||||||
|
const skillManager = config.getSkillManager();
|
||||||
|
const toolRegistry = config.getToolRegistry();
|
||||||
|
|
||||||
|
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(toolRegistry, 'registerTool');
|
||||||
|
vi.spyOn(toolRegistry, 'unregisterTool');
|
||||||
|
|
||||||
|
vi.spyOn(skillManager, 'getSkills').mockReturnValue([]);
|
||||||
|
|
||||||
|
await config.reloadSkills();
|
||||||
|
|
||||||
|
expect(toolRegistry.unregisterTool).toHaveBeenCalledWith(
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear disabledSkills when onReload returns undefined for them', async () => {
|
||||||
|
const mockOnReload = vi.fn().mockResolvedValue({
|
||||||
|
disabledSkills: undefined,
|
||||||
|
});
|
||||||
|
const params: ConfigParameters = {
|
||||||
|
sessionId: 'test-session',
|
||||||
|
targetDir: '/tmp/test',
|
||||||
|
debugMode: false,
|
||||||
|
model: 'test-model',
|
||||||
|
cwd: '/tmp/test',
|
||||||
|
skillsSupport: true,
|
||||||
|
onReload: mockOnReload,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = new Config(params);
|
||||||
|
// Initially set some disabled skills
|
||||||
|
// @ts-expect-error - accessing private
|
||||||
|
config.disabledSkills = ['skill1'];
|
||||||
|
await config.initialize();
|
||||||
|
|
||||||
|
const skillManager = config.getSkillManager();
|
||||||
|
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(skillManager, 'setDisabledSkills');
|
||||||
|
|
||||||
|
await config.reloadSkills();
|
||||||
|
|
||||||
|
expect(skillManager.setDisabledSkills).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export interface ConfigParameters {
|
|||||||
disabledSkills?: string[];
|
disabledSkills?: string[];
|
||||||
experimentalJitContext?: boolean;
|
experimentalJitContext?: boolean;
|
||||||
onModelChange?: (model: string) => void;
|
onModelChange?: (model: string) => void;
|
||||||
|
onReload?: () => Promise<{ disabledSkills?: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
@@ -479,10 +480,13 @@ export class Config {
|
|||||||
private experimentsPromise: Promise<void> | undefined;
|
private experimentsPromise: Promise<void> | undefined;
|
||||||
private hookSystem?: HookSystem;
|
private hookSystem?: HookSystem;
|
||||||
private readonly onModelChange: ((model: string) => void) | undefined;
|
private readonly onModelChange: ((model: string) => void) | undefined;
|
||||||
|
private readonly onReload:
|
||||||
|
| (() => Promise<{ disabledSkills?: string[] }>)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
private readonly enableAgents: boolean;
|
private readonly enableAgents: boolean;
|
||||||
private readonly skillsSupport: boolean;
|
private readonly skillsSupport: boolean;
|
||||||
private readonly disabledSkills: string[];
|
private disabledSkills: string[];
|
||||||
|
|
||||||
private readonly experimentalJitContext: boolean;
|
private readonly experimentalJitContext: boolean;
|
||||||
private contextManager?: ContextManager;
|
private contextManager?: ContextManager;
|
||||||
@@ -643,6 +647,7 @@ export class Config {
|
|||||||
this.projectHooks = params.projectHooks;
|
this.projectHooks = params.projectHooks;
|
||||||
this.experiments = params.experiments;
|
this.experiments = params.experiments;
|
||||||
this.onModelChange = params.onModelChange;
|
this.onModelChange = params.onModelChange;
|
||||||
|
this.onReload = params.onReload;
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
@@ -1520,6 +1525,38 @@ export class Config {
|
|||||||
return this.skillsSupport;
|
return this.skillsSupport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads skills by re-discovering them from extensions and local directories.
|
||||||
|
*/
|
||||||
|
async reloadSkills(): Promise<void> {
|
||||||
|
if (!this.skillsSupport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onReload) {
|
||||||
|
const refreshed = await this.onReload();
|
||||||
|
this.disabledSkills = refreshed.disabledSkills ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getSkillManager().discoverSkills(
|
||||||
|
this.storage,
|
||||||
|
this.getExtensions(),
|
||||||
|
);
|
||||||
|
this.getSkillManager().setDisabledSkills(this.disabledSkills);
|
||||||
|
|
||||||
|
// Re-register ActivateSkillTool to update its schema with the newly discovered skills
|
||||||
|
if (this.getSkillManager().getSkills().length > 0) {
|
||||||
|
this.getToolRegistry().registerTool(
|
||||||
|
new ActivateSkillTool(this, this.messageBus),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that system instructions might need updating
|
||||||
|
await this.updateSystemInstructionIfInitialized();
|
||||||
|
}
|
||||||
|
|
||||||
isInteractive(): boolean {
|
isInteractive(): boolean {
|
||||||
return this.interactive;
|
return this.interactive;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ export class ToolRegistry {
|
|||||||
this.allKnownTools.set(tool.name, tool);
|
this.allKnownTools.set(tool.name, tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a tool definition by name.
|
||||||
|
*
|
||||||
|
* @param name - The name of the tool to unregister.
|
||||||
|
*/
|
||||||
|
unregisterTool(name: string): void {
|
||||||
|
this.allKnownTools.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts tools as:
|
* Sorts tools as:
|
||||||
* 1. Built in tools.
|
* 1. Built in tools.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export enum CoreEvent {
|
|||||||
Output = 'output',
|
Output = 'output',
|
||||||
MemoryChanged = 'memory-changed',
|
MemoryChanged = 'memory-changed',
|
||||||
ExternalEditorClosed = 'external-editor-closed',
|
ExternalEditorClosed = 'external-editor-closed',
|
||||||
|
SettingsChanged = 'settings-changed',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreEvents {
|
export interface CoreEvents {
|
||||||
@@ -83,6 +84,7 @@ export interface CoreEvents {
|
|||||||
[CoreEvent.Output]: [OutputPayload];
|
[CoreEvent.Output]: [OutputPayload];
|
||||||
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
||||||
[CoreEvent.ExternalEditorClosed]: never[];
|
[CoreEvent.ExternalEditorClosed]: never[];
|
||||||
|
[CoreEvent.SettingsChanged]: never[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBacklogItem = {
|
type EventBacklogItem = {
|
||||||
@@ -163,6 +165,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
|||||||
this.emit(CoreEvent.ModelChanged, payload);
|
this.emit(CoreEvent.ModelChanged, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies subscribers that settings have been modified.
|
||||||
|
*/
|
||||||
|
emitSettingsChanged(): void {
|
||||||
|
this.emit(CoreEvent.SettingsChanged);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flushes buffered messages. Call this immediately after primary UI listener
|
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||||
* subscribes.
|
* subscribes.
|
||||||
|
|||||||
Reference in New Issue
Block a user