mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
🎚️ feat: Explicit "Use All Skills" Toggle in the Skills Section (#14125)
* feat: Add explicit Use All Skills toggle to the Skills section Adding a skill to an agent could silently mean "all skills enabled" (skills_enabled on with an empty allowlist), and the only master control was the Agent Skills kill switch buried in the Advanced panel, which implied skills required a trip to Advanced at all. - Add a "Use all skills" switch directly in the builder's Skills section: on = the agent gets every skill the user can access, including skills added in the future (skills_enabled true, empty allowlist). While on, the per-skill list and Add button are hidden and the header shows an All badge; turning it off restores the previously selected skills. - Remove the kill switch from the Advanced panel. - Simplify skillsEnabledTransition so the master flag tracks the selection on every edit, which also re-enables agents left with a selection but a disabled flag by the old kill switch. * fix: Normalize legacy disabled allowlists and scope the use-all stash per agent Agents saved with the removed Advanced kill switch off (or predating the skills_enabled flag) load a non-empty allowlist with the master flag not true. The builder rendered that selection as active while the runtime ignored it, with no control left to re-enable. Normalize on form load so the flag matches the displayed selection. The use-all stash lives in a ref and ToolsSection is not remounted when switching agents (only the form resets), so toggling off on another agent could restore the previous agent's allowlist. Clear the stash when the agent id changes.
This commit is contained in:
parent
8fcb77fe6f
commit
a0aa1f2b9d
9 changed files with 254 additions and 113 deletions
|
|
@ -1,15 +1,13 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { ChevronLeft, Check, Copy } from 'lucide-react';
|
||||
import { AgentCapabilities, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { sectionLabelClass, groupHeadingClass } from './ui';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import OrchestrationHub from './OrchestrationHub';
|
||||
import MaxAgentSteps from './MaxAgentSteps';
|
||||
import SkillsToggle from './SkillsToggle';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AdvancedPanel() {
|
||||
|
|
@ -19,7 +17,7 @@ export default function AdvancedPanel() {
|
|||
const currentAgentId = watch('id');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { agentsConfig, setActivePanel } = useAgentPanelContext();
|
||||
const { setActivePanel } = useAgentPanelContext();
|
||||
|
||||
const handleCopyAgentId = async () => {
|
||||
if (!currentAgentId) return;
|
||||
|
|
@ -33,16 +31,6 @@ export default function AdvancedPanel() {
|
|||
}
|
||||
};
|
||||
|
||||
const skillsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.skills) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const hasSkillsAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const showSkillsKillSwitch = skillsEnabled && hasSkillsAccess;
|
||||
|
||||
return (
|
||||
<div className="mb-1 flex w-full flex-col gap-4 text-sm">
|
||||
<header className="grid grid-cols-[auto_1fr_auto] items-center gap-2 pt-1">
|
||||
|
|
@ -64,7 +52,6 @@ export default function AdvancedPanel() {
|
|||
<section className="flex flex-col gap-3">
|
||||
<span className={groupHeadingClass}>{localize('com_ui_essentials')}</span>
|
||||
<MaxAgentSteps />
|
||||
{showSkillsKillSwitch && <SkillsToggle />}
|
||||
</section>
|
||||
|
||||
<OrchestrationHub currentAgentId={currentAgentId} />
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ToggleSetting } from './ui';
|
||||
|
||||
const id = 'skills_enabled_killswitch';
|
||||
|
||||
export default function SkillsToggle() {
|
||||
const localize = useLocalize();
|
||||
const { control } = useFormContext<AgentForm>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="skills_enabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ToggleSetting
|
||||
id={id}
|
||||
label={localize('com_ui_tools_skills_enabled_kill_switch')}
|
||||
checked={field.value === true}
|
||||
onCheckedChange={(value) => field.onChange(Boolean(value))}
|
||||
info={
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_tools_skills_enabled_kill_switch_hint')}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -150,6 +150,20 @@ function AgentSelect({
|
|||
}
|
||||
});
|
||||
|
||||
/** Legacy state from the removed Advanced kill switch: a non-empty
|
||||
* allowlist with the master flag off (or unset, for agents predating
|
||||
* the flag). The builder has no control left for it and the runtime
|
||||
* treats it as "no skills", yet the section would render the selection
|
||||
* as active. Normalize to enabled so the form matches what the UI
|
||||
* shows and a later save persists the displayed behavior. */
|
||||
if (
|
||||
Array.isArray(formValues.skills) &&
|
||||
formValues.skills.length > 0 &&
|
||||
formValues.skills_enabled !== true
|
||||
) {
|
||||
formValues.skills_enabled = true;
|
||||
}
|
||||
|
||||
reset(formValues);
|
||||
},
|
||||
[reset],
|
||||
|
|
|
|||
|
|
@ -111,9 +111,8 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
|
||||
const applySkillsSelection = useCallback(
|
||||
(next: string[]) => {
|
||||
const current = (getValues('skills') ?? []) as string[];
|
||||
setValue('skills', next, { shouldDirty: true });
|
||||
const flag = skillsEnabledTransition(current, next, getValues('skills_enabled'));
|
||||
const flag = skillsEnabledTransition(next, getValues('skills_enabled'));
|
||||
if (flag !== undefined) {
|
||||
setValue('skills_enabled', flag, { shouldDirty: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Label, OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-provider';
|
||||
import {
|
||||
Label,
|
||||
Switch,
|
||||
OGDialog,
|
||||
HoverCard,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
OGDialogTemplate,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentItem } from './items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
|
|
@ -18,10 +28,13 @@ import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
|
|||
import ToolsMarketplaceDialog from './ToolsMarketplaceDialog';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { isEphemeralAgent, ESide } from '~/common';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import { InfoTrigger } from '../Advanced/ui';
|
||||
import { Collapse } from '~/components/ui';
|
||||
import SkillsDialog from './SkillsDialog';
|
||||
import ToolRow from './ToolRow';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
|
|
@ -36,7 +49,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
const [pendingActionRemoval, setPendingActionRemoval] = useState<string | null>(null);
|
||||
const [pendingMcpRemoval, setPendingMcpRemoval] = useState<string | null>(null);
|
||||
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { control, getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { agentsConfig, regularTools, mcpServersMap } = useAgentPanelContext();
|
||||
const { removeTool: removeMCPTool } = useRemoveMCPTool();
|
||||
const deleteAgentAction = useDeleteAgentAction({
|
||||
|
|
@ -66,6 +79,36 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
|
||||
const resolvedSkills = useResolvedSkills(skillsData?.skills);
|
||||
|
||||
const skillsValue = useWatch({ control, name: 'skills' });
|
||||
const skillsEnabledValue = useWatch({ control, name: 'skills_enabled' });
|
||||
const useAllSkills = skillsEnabledValue === true && (skillsValue ?? []).length === 0;
|
||||
/** Selection stashed when "use all skills" turns on, so turning it back off
|
||||
* restores the previous picks instead of destroying them. Cleared when the
|
||||
* agent changes — the section isn't remounted on switch (only the form
|
||||
* resets), so a stale stash could otherwise restore one agent's allowlist
|
||||
* into another. */
|
||||
const stashedSkillsRef = useRef<string[]>([]);
|
||||
const [prevAgentId, setPrevAgentId] = useState(agentId);
|
||||
if (prevAgentId !== agentId) {
|
||||
setPrevAgentId(agentId);
|
||||
stashedSkillsRef.current = [];
|
||||
}
|
||||
|
||||
const handleUseAllSkillsChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
stashedSkillsRef.current = (getValues('skills') ?? []) as string[];
|
||||
setValue('skills', [], { shouldDirty: true });
|
||||
setValue('skills_enabled', true, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
const restored = stashedSkillsRef.current;
|
||||
setValue('skills', restored, { shouldDirty: true });
|
||||
setValue('skills_enabled', restored.length > 0, { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue],
|
||||
);
|
||||
|
||||
const uninstallToolCredentials = useUninstallToolCredentials();
|
||||
const { knowledgeFiles, codeFiles } = useAgentFileEntries();
|
||||
|
||||
|
|
@ -123,7 +166,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
const current = (getValues('skills') ?? []) as string[];
|
||||
const next = current.filter((s) => s !== patch.id);
|
||||
setValue('skills', next, { shouldDirty: true });
|
||||
const flag = skillsEnabledTransition(current, next, getValues('skills_enabled'));
|
||||
const flag = skillsEnabledTransition(next, getValues('skills_enabled'));
|
||||
if (flag !== undefined) {
|
||||
setValue('skills_enabled', flag, { shouldDirty: true });
|
||||
}
|
||||
|
|
@ -240,7 +283,37 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
onAdd={() => setSkillsOpen(true)}
|
||||
onInfo={setDialogItem}
|
||||
onRemove={handleQuickRemove}
|
||||
/>
|
||||
badgeText={useAllSkills ? localize('com_ui_all_proper') : undefined}
|
||||
showAdd={!useAllSkills}
|
||||
showBody={!useAllSkills}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
id="use-all-skills-label"
|
||||
className="truncate text-[13px] font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_skills_use_all')}
|
||||
</span>
|
||||
<HoverCard openDelay={50}>
|
||||
<InfoTrigger />
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_skills_use_all_hint')}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<Switch
|
||||
id="use-all-skills"
|
||||
checked={useAllSkills}
|
||||
onCheckedChange={handleUseAllSkillsChange}
|
||||
aria-labelledby="use-all-skills-label"
|
||||
/>
|
||||
</div>
|
||||
</SelectedSection>
|
||||
)}
|
||||
{open && <ToolsMarketplaceDialog open={open} onOpenChange={setOpen} agentId={agentId} />}
|
||||
{skillsOpen && (
|
||||
|
|
@ -310,6 +383,12 @@ interface SelectedSectionProps {
|
|||
onAdd: () => void;
|
||||
onInfo: (item: AgentItem) => void;
|
||||
onRemove: (item: AgentItem) => void;
|
||||
/** Replaces the item-count badge next to the title. */
|
||||
badgeText?: string;
|
||||
showAdd?: boolean;
|
||||
showBody?: boolean;
|
||||
/** Rendered between the header and the item list / empty state. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function SelectedSection({
|
||||
|
|
@ -321,16 +400,21 @@ function SelectedSection({
|
|||
onAdd,
|
||||
onInfo,
|
||||
onRemove,
|
||||
badgeText,
|
||||
showAdd = true,
|
||||
showBody = true,
|
||||
children,
|
||||
}: SelectedSectionProps) {
|
||||
const localize = useLocalize();
|
||||
const badge = badgeText ?? (items.length > 0 ? String(items.length) : undefined);
|
||||
return (
|
||||
<div className="mb-3 flex flex-col">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className="block text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{title}
|
||||
{items.length > 0 && (
|
||||
{badge != null && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-surface-tertiary px-1.5 text-[10px] font-medium normal-case tracking-normal text-text-secondary">
|
||||
{items.length}
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
|
@ -338,31 +422,39 @@ function SelectedSection({
|
|||
type="button"
|
||||
onClick={onAdd}
|
||||
aria-label={addLabel}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
aria-hidden={!showAdd || undefined}
|
||||
tabIndex={showAdd ? 0 : -1}
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
!showAdd && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||
{localize('com_ui_add')}
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex w-full flex-col items-center gap-1 rounded-xl border border-dashed border-border-light px-2 py-4 text-text-secondary transition-colors hover:border-border-medium hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="text-xs">{emptyLabel}</span>
|
||||
<span className="text-[11px] text-text-secondary">{emptyHint}</span>
|
||||
</button>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}:${item.id}`}>
|
||||
<ToolRow item={item} onInfo={onInfo} onRemove={onRemove} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{children}
|
||||
<Collapse open={showBody}>
|
||||
{items.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex w-full flex-col items-center gap-1 rounded-xl border border-dashed border-border-light px-2 py-4 text-text-secondary transition-colors hover:border-border-medium hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="text-xs">{emptyLabel}</span>
|
||||
<span className="text-[11px] text-text-secondary">{emptyHint}</span>
|
||||
</button>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}:${item.id}`}>
|
||||
<ToolRow item={item} onInfo={onInfo} onRemove={onRemove} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ let mockFileEntries: {
|
|||
codeFiles: unknown[];
|
||||
} = { contextFiles: [], knowledgeFiles: [], codeFiles: [] };
|
||||
const mockSetValue = jest.fn();
|
||||
let mockFormValues: Record<string, unknown> = {};
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
control: {},
|
||||
getValues: () => [],
|
||||
getValues: (name: string) => mockFormValues[name],
|
||||
setValue: mockSetValue,
|
||||
}),
|
||||
useWatch: () => undefined,
|
||||
useWatch: ({ name }: { name: string }) => mockFormValues[name],
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
|
|
@ -73,6 +74,28 @@ jest.mock('@librechat/client', () => ({
|
|||
OGDialog: ({ open, children }: { open?: boolean; children?: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
OGDialogTemplate: () => null,
|
||||
Switch: ({
|
||||
id,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id?: string;
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
/>
|
||||
),
|
||||
HoverCard: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
HoverCardPortal: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
HoverCardContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
HoverCardTrigger: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
CircleHelpIcon: () => <svg aria-hidden="true" />,
|
||||
}));
|
||||
|
||||
jest.mock('../ToolsMarketplaceDialog', () => ({
|
||||
|
|
@ -91,6 +114,7 @@ const fileSearchItem: AgentItem = {
|
|||
beforeEach(() => {
|
||||
mockSelected = [];
|
||||
mockFileEntries = { contextFiles: [], knowledgeFiles: [], codeFiles: [] };
|
||||
mockFormValues = {};
|
||||
mockSetValue.mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -137,3 +161,67 @@ describe('ToolsSection', () => {
|
|||
expect(mockSetValue).toHaveBeenCalledWith('file_search', false, { shouldDirty: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('use all skills toggle', () => {
|
||||
test('renders off inside the Skills section by default', () => {
|
||||
render(<ToolsSection agentId="a" />);
|
||||
expect(screen.getByText('com_ui_skills_use_all')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_skills_use_all_hint')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
test('turning it on clears the selection and enables the master flag', () => {
|
||||
mockFormValues = { skills: ['s1'], skills_enabled: true };
|
||||
render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', true, { shouldDirty: true });
|
||||
});
|
||||
|
||||
test('while on, hides Add and the skill list and shows the All badge', () => {
|
||||
mockFormValues = { skills: [], skills_enabled: true };
|
||||
render(<ToolsSection agentId="a" />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_add_skills' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /com_ui_skills_empty/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_all_proper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking the label does not toggle the switch', () => {
|
||||
render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByText('com_ui_skills_use_all'));
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('turning it off restores the previously selected skills', () => {
|
||||
mockFormValues = { skills: ['s1'], skills_enabled: true };
|
||||
const { rerender } = render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
mockFormValues = { skills: [], skills_enabled: true };
|
||||
rerender(<ToolsSection agentId="a" />);
|
||||
mockSetValue.mockClear();
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills', ['s1'], { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', true, { shouldDirty: true });
|
||||
});
|
||||
|
||||
test('does not restore a stash from a different agent', () => {
|
||||
mockFormValues = { skills: ['s1'], skills_enabled: true };
|
||||
const { rerender } = render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
mockFormValues = { skills: [], skills_enabled: true };
|
||||
rerender(<ToolsSection agentId="b" />);
|
||||
mockSetValue.mockClear();
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', false, { shouldDirty: true });
|
||||
});
|
||||
|
||||
test('turning it off with nothing stashed disables the master flag', () => {
|
||||
mockFormValues = { skills: [], skills_enabled: true };
|
||||
render(<ToolsSection agentId="a" />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
|
||||
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', false, { shouldDirty: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,28 +132,23 @@ describe('computeToggleAction', () => {
|
|||
});
|
||||
|
||||
describe('skillsEnabledTransition', () => {
|
||||
test('adding the first skill turns the master flag on', () => {
|
||||
expect(skillsEnabledTransition([], ['s1'], undefined)).toBe(true);
|
||||
expect(skillsEnabledTransition([], ['s1'], false)).toBe(true);
|
||||
test('a non-empty selection turns the master flag on when it is off', () => {
|
||||
expect(skillsEnabledTransition(['s1'], undefined)).toBe(true);
|
||||
expect(skillsEnabledTransition(['s1'], false)).toBe(true);
|
||||
expect(skillsEnabledTransition(['s1', 's2'], false)).toBe(true);
|
||||
});
|
||||
|
||||
test('adding the first skill leaves an already-on flag alone', () => {
|
||||
expect(skillsEnabledTransition([], ['s1'], true)).toBeUndefined();
|
||||
test('a non-empty selection leaves an already-on flag alone', () => {
|
||||
expect(skillsEnabledTransition(['s1'], true)).toBeUndefined();
|
||||
expect(skillsEnabledTransition(['s1', 's2'], true)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('removing the last skill turns the master flag off', () => {
|
||||
expect(skillsEnabledTransition(['s1'], [], true)).toBe(false);
|
||||
test('clearing the selection turns the master flag off', () => {
|
||||
expect(skillsEnabledTransition([], true)).toBe(false);
|
||||
});
|
||||
|
||||
test('removing the last skill leaves an off flag alone', () => {
|
||||
expect(skillsEnabledTransition(['s1'], [], false)).toBeUndefined();
|
||||
expect(skillsEnabledTransition(['s1'], [], undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('edits within a non-empty selection never touch the flag', () => {
|
||||
expect(skillsEnabledTransition(['s1'], ['s1', 's2'], true)).toBeUndefined();
|
||||
expect(skillsEnabledTransition(['s1'], ['s1', 's2'], false)).toBeUndefined();
|
||||
expect(skillsEnabledTransition(['s1', 's2'], ['s1'], true)).toBeUndefined();
|
||||
expect(skillsEnabledTransition(['s1', 's2'], ['s1'], false)).toBeUndefined();
|
||||
test('an empty selection leaves an off flag alone', () => {
|
||||
expect(skillsEnabledTransition([], false)).toBeUndefined();
|
||||
expect(skillsEnabledTransition([], undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,24 +48,21 @@ export function computeToggleAction(item: AgentItem, state: { selected: boolean
|
|||
|
||||
/**
|
||||
* `skills_enabled` is the master opt-in for the skill allowlist, and an empty
|
||||
* allowlist with the flag on means the FULL accessible catalog. Selection
|
||||
* edits therefore sync the flag on empty/non-empty transitions: picking the
|
||||
* first skill turns it on so the choice takes effect, and removing the last
|
||||
* one turns it off so the agent doesn't silently escalate to every skill.
|
||||
* Edits within a non-empty selection return `undefined` (leave the flag
|
||||
* alone), preserving the Advanced kill switch's disable-without-clearing
|
||||
* behavior.
|
||||
* allowlist with the flag on means the FULL accessible catalog ("use all
|
||||
* skills"). Selection edits sync the flag to the selection: any non-empty
|
||||
* selection needs the flag on to take effect (this also heals agents saved
|
||||
* with the since-removed Advanced kill switch off while skills were still
|
||||
* selected), and clearing the selection turns it off so the agent doesn't
|
||||
* silently escalate to the full catalog. Returns `undefined` when the flag
|
||||
* already matches. The "use all skills" state (flag on, empty selection) is
|
||||
* only ever set by its explicit toggle, never by selection edits.
|
||||
*/
|
||||
export function skillsEnabledTransition(
|
||||
current: string[],
|
||||
next: string[],
|
||||
enabled: boolean | undefined,
|
||||
): boolean | undefined {
|
||||
if (current.length === 0 && next.length > 0 && enabled !== true) {
|
||||
return true;
|
||||
if (next.length > 0) {
|
||||
return enabled === true ? undefined : true;
|
||||
}
|
||||
if (current.length > 0 && next.length === 0 && enabled === true) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
return enabled === true ? false : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1794,6 +1794,8 @@
|
|||
"com_ui_skills_load_error": "Failed to load skills",
|
||||
"com_ui_skills_manual_invoked": "Manually invoked skills",
|
||||
"com_ui_skills_queued": "Skills queued for next submission",
|
||||
"com_ui_skills_use_all": "Use all skills",
|
||||
"com_ui_skills_use_all_hint": "The agent can use every skill available to you, including skills added in the future.",
|
||||
"com_ui_sort_by": "Sort by",
|
||||
"com_ui_sort_chats_by": "Sort chats by",
|
||||
"com_ui_sort_created": "Created",
|
||||
|
|
@ -1903,8 +1905,6 @@
|
|||
"com_ui_tools_search_no_results": "Nothing matched your search",
|
||||
"com_ui_tools_section_title": "Tools",
|
||||
"com_ui_tools_shared_by": "Shared by {{name}}",
|
||||
"com_ui_tools_skills_enabled_kill_switch": "Agent skills",
|
||||
"com_ui_tools_skills_enabled_kill_switch_hint": "Master switch for this agent's skills. When off, the agent ignores every skill you've selected, letting you disable them all at once without clearing your selection.",
|
||||
"com_ui_tools_view_favorites": "Favorites",
|
||||
"com_ui_tools_view_favorites_empty": "You haven't favorited anything yet",
|
||||
"com_ui_tools_view_made_by_you": "Made by you",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue