mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-24 22:03:46 +00:00
fix(skills): scope priority to /skills listing only
Earlier in this PR, `skill.priority` was mapped into `SlashCommand.completionPriority`
on both bundled and non-bundled skill loaders, so a high-priority skill
also bubbled up in the slash-completion menu and the `/help` custom-commands
tab. That was broader than intended — the design goal is for `priority:`
to control the `/skills` listing only, with everything else (typing `/`,
mid-input completion, `/help`) staying purely alphabetical so a skill
can't reorder built-in commands.
Changes:
- BundledSkillLoader / SkillCommandLoader: drop the
`completionPriority: skill.priority` mapping. Skill commands now have
no `completionPriority`, falling back to alphabetical+recency in the
shared completion comparator.
- Help.tsx: revert the per-group sort to `localeCompare` and remove the
`compareCommandsForHelp` helper. `/help` is again purely alphabetical
within each group.
- Tests:
- Both loader tests assert `completionPriority` is `undefined` when
a skill has a `priority` set, locking the non-leakage in.
- Help.test.tsx's "orders by completionPriority" case is replaced
with "orders alphabetically regardless of completionPriority", so a
future change that re-introduces the leak fails the test.
- Extension-skill validation also normalizes `skill.priority` to 0 (in
addition to the existing sort-time normalization) so downstream
consumers see a clean value matching the emitted warning.
Validation:
- 177/177 unit tests pass across the 5 affected test files
- core typecheck clean
- bundled CLI built (`npm run bundle`) and exercised via tmux E2E:
E1 /skills sorted by priority, E2 / completion menu unaffected,
E3 mid-input alphabetical, E4 invalid priority warns + skill loads,
E5 order stable across restart — all 5 pass.
This commit is contained in:
parent
757c220798
commit
96722aa67e
8 changed files with 54 additions and 20 deletions
|
|
@ -80,7 +80,7 @@ describe('BundledSkillLoader', () => {
|
|||
});
|
||||
|
||||
it('should load bundled skills as slash commands', async () => {
|
||||
const skill = makeSkill({ priority: 42 });
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
|
|
@ -90,12 +90,25 @@ describe('BundledSkillLoader', () => {
|
|||
expect(commands[0].name).toBe('review');
|
||||
expect(commands[0].description).toBe('Review code changes');
|
||||
expect(commands[0].kind).toBe(CommandKind.SKILL);
|
||||
expect(commands[0].completionPriority).toBe(42);
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({
|
||||
level: 'bundled',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not propagate skill.priority to completionPriority', async () => {
|
||||
// Priority is intentionally scoped to the `/skills` listing (sorted in
|
||||
// SkillManager.listSkills) and must NOT leak into the slash-completion
|
||||
// menu / `/help` ordering — typing `/` should keep its prior behavior
|
||||
// regardless of any skill's priority value.
|
||||
const skill = makeSkill({ priority: 42 });
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].completionPriority).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should submit skill body as prompt without args', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
modelInvocable: !skill.disableModelInvocation,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
completionPriority: skill.priority,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Resolve template variables in skill body
|
||||
let body = skill.body;
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ describe('SkillCommandLoader', () => {
|
|||
});
|
||||
|
||||
it('should load user skill as slash command with correct properties', async () => {
|
||||
const skill = makeSkill({ level: 'user', priority: 42 });
|
||||
const skill = makeSkill({ level: 'user' });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
|
|
@ -102,7 +102,21 @@ describe('SkillCommandLoader', () => {
|
|||
expect(cmd.sourceLabel).toBe('User');
|
||||
expect(cmd.sourceDetail).toBe('user');
|
||||
expect(cmd.modelInvocable).toBe(true);
|
||||
expect(cmd.completionPriority).toBe(42);
|
||||
});
|
||||
|
||||
it('does not propagate skill.priority to completionPriority', async () => {
|
||||
// Priority is scoped to the `/skills` listing only; slash-completion /
|
||||
// `/help` ordering should be independent of any skill's priority value.
|
||||
const skill = makeSkill({ level: 'user', priority: 42 });
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands[0].completionPriority).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load project skill with sourceLabel "Project"', async () => {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ export class SkillCommandLoader implements ICommandLoader {
|
|||
modelInvocable,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
completionPriority: skill.priority,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
const body = skill.body;
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,10 @@ describe('Help Component', () => {
|
|||
expect(output).not.toContain('/test');
|
||||
});
|
||||
|
||||
it('orders help commands by completionPriority before name', () => {
|
||||
it('orders help commands alphabetically regardless of completionPriority', () => {
|
||||
// Skill priority is scoped to the /skills listing; /help intentionally
|
||||
// stays alphabetical so a high-priority skill can't push a built-in
|
||||
// command around in the help view.
|
||||
const commands: SlashCommand[] = [
|
||||
{
|
||||
name: 'alpha',
|
||||
|
|
@ -217,8 +220,8 @@ describe('Help Component', () => {
|
|||
);
|
||||
const output = lastFrame() ?? '';
|
||||
|
||||
expect(output.indexOf('/zeta')).toBeLessThan(output.indexOf('/alpha'));
|
||||
expect(output.indexOf('/alpha')).toBeLessThan(output.indexOf('/beta'));
|
||||
expect(output.indexOf('/beta')).toBeLessThan(output.indexOf('/zeta'));
|
||||
});
|
||||
|
||||
it('switches tabs with Tab and Shift+Tab when interactive', () => {
|
||||
|
|
|
|||
|
|
@ -497,17 +497,10 @@ function groupCommands(
|
|||
.sort((a, b) => a.order - b.order)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
commands: group.commands.sort(compareCommandsForHelp),
|
||||
commands: group.commands.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
function compareCommandsForHelp(a: SlashCommand, b: SlashCommand): number {
|
||||
return (
|
||||
(b.completionPriority ?? 0) - (a.completionPriority ?? 0) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (maxLength <= 1 || text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
|
|
|
|||
|
|
@ -828,6 +828,10 @@ Body`);
|
|||
'high-priority',
|
||||
'bad-priority',
|
||||
]);
|
||||
// The non-number priority should also be normalized on the skill itself,
|
||||
// not just at sort time, so downstream consumers see a clean value.
|
||||
const badSkill = skills.find((s) => s.name === 'bad-priority');
|
||||
expect(badSkill?.priority).toBe(0);
|
||||
});
|
||||
|
||||
it('should deduplicate same-name skills across provider dirs within a level', async () => {
|
||||
|
|
|
|||
|
|
@ -898,16 +898,25 @@ export class SkillManager {
|
|||
// non-number `priority` would silently sort at the bottom (via
|
||||
// normalizeSkillPriority) with no diagnostic. Warn here so the
|
||||
// extension author sees the same signal a SKILL.md author would.
|
||||
if (
|
||||
const hasInvalidPriority =
|
||||
skill.priority !== undefined &&
|
||||
(typeof skill.priority !== 'number' ||
|
||||
!Number.isFinite(skill.priority))
|
||||
) {
|
||||
!Number.isFinite(skill.priority));
|
||||
if (hasInvalidPriority) {
|
||||
debugLogger.warn(
|
||||
`Extension "${extension.name}" skill "${skill.name}" has invalid priority (${typeof skill.priority}: ${String(skill.priority)}); treating as 0.`,
|
||||
);
|
||||
}
|
||||
skills.push({ ...skill, extensionName: extension.name });
|
||||
skills.push({
|
||||
...skill,
|
||||
extensionName: extension.name,
|
||||
// Normalize so downstream consumers reading `skill.priority`
|
||||
// observe the same value reflected by the warning above and by
|
||||
// sort-time normalization.
|
||||
priority: hasInvalidPriority
|
||||
? 0
|
||||
: (skill.priority as number | undefined),
|
||||
});
|
||||
});
|
||||
}
|
||||
debugLogger.debug(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue