qwen-code/packages/cli/src/services/commandUtils.test.ts
顾盼 a82d766727
refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) (#3283)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)

## Summary

Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.

## Key changes

### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
  'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
  supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
  examples (all optional, backward-compatible)

### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
  (explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests

### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)

### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use

### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
  init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands

### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
  modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
  commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true

### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.

### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
  capability filtering is now self-contained in CommandService

* fix test ci

* fix memory command

* fix: pass 'non_interactive' mode explicitly to getAvailableCommands

- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
  calling getAvailableCommands without specifying mode, causing it to default
  to 'acp' instead of 'non_interactive'. Commands with supportedModes that
  include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
  instead of duplicating the logic inline, preventing divergence.

Fixes review comments by wenshao and tanzhenxin on PR #3283.

* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts

* fix test ci
2026-04-20 14:34:43 +08:00

212 lines
6.6 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getEffectiveSupportedModes,
filterCommandsForMode,
} from './commandUtils.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
/** Minimal SlashCommand factory for tests */
function makeCmd(overrides: Partial<SlashCommand>): SlashCommand {
return {
name: 'test',
description: 'test command',
kind: CommandKind.BUILT_IN,
...overrides,
};
}
describe('getEffectiveSupportedModes', () => {
// ── Priority 1: explicit supportedModes ───────────────────────────────
it('explicit supportedModes overrides commandType inference', () => {
const cmd = makeCmd({
commandType: 'local',
supportedModes: ['interactive'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('explicit supportedModes can expand to all modes even for local-jsx', () => {
const cmd = makeCmd({
commandType: 'local-jsx',
supportedModes: ['interactive', 'non_interactive', 'acp'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('explicit empty supportedModes returns empty array', () => {
const cmd = makeCmd({ supportedModes: [] });
expect(getEffectiveSupportedModes(cmd)).toEqual([]);
});
// ── Priority 2: commandType inference ─────────────────────────────────
it('commandType: prompt infers all modes', () => {
const cmd = makeCmd({ kind: CommandKind.SKILL, commandType: 'prompt' });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('commandType: local infers interactive only (conservative default)', () => {
const cmd = makeCmd({ commandType: 'local' });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: local-jsx infers interactive only', () => {
const cmd = makeCmd({ commandType: 'local-jsx' });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: local with explicit supportedModes can unlock non_interactive', () => {
const cmd = makeCmd({
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
// ── Priority 3: CommandKind fallback (backward compat) ────────────────
it('no commandType, CommandKind.BUILT_IN falls back to interactive only', () => {
const cmd = makeCmd({ kind: CommandKind.BUILT_IN });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('no commandType, CommandKind.FILE falls back to all modes', () => {
const cmd = makeCmd({ kind: CommandKind.FILE });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('no commandType, CommandKind.SKILL falls back to all modes', () => {
const cmd = makeCmd({ kind: CommandKind.SKILL });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => {
const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
});
describe('filterCommandsForMode', () => {
const commands: SlashCommand[] = [
makeCmd({
name: 'init',
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
}),
makeCmd({
name: 'model',
commandType: 'local-jsx',
// no explicit supportedModes → interactive only
}),
makeCmd({
name: 'review',
kind: CommandKind.SKILL,
commandType: 'prompt',
}),
makeCmd({
name: 'gh-prompt',
kind: CommandKind.MCP_PROMPT,
commandType: 'prompt',
}),
makeCmd({
name: 'my-script',
kind: CommandKind.FILE,
commandType: 'prompt',
}),
];
it('interactive mode includes all commands', () => {
const result = filterCommandsForMode(commands, 'interactive');
expect(result.map((c) => c.name)).toEqual([
'init',
'model',
'review',
'gh-prompt',
'my-script',
]);
});
it('non_interactive mode excludes local-jsx commands', () => {
const result = filterCommandsForMode(commands, 'non_interactive');
expect(result.map((c) => c.name)).toEqual([
'init',
'review',
'gh-prompt',
'my-script',
]);
});
it('acp mode excludes local-jsx commands', () => {
const result = filterCommandsForMode(commands, 'acp');
expect(result.map((c) => c.name)).toEqual([
'init',
'review',
'gh-prompt',
'my-script',
]);
});
it('non_interactive includes MCP_PROMPT commands (bug fix)', () => {
const result = filterCommandsForMode(commands, 'non_interactive');
expect(result.some((c) => c.name === 'gh-prompt')).toBe(true);
});
it('does not filter hidden commands (hidden filtering is caller responsibility)', () => {
const withHidden = [
...commands,
makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }),
];
const result = filterCommandsForMode(withHidden, 'non_interactive');
// filterCommandsForMode does NOT filter hidden — it only filters by mode
// hidden-cmd has commandType: 'local' but no supportedModes, so it's interactive only
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(false);
});
it('hidden local command with explicit supportedModes still passes mode filter', () => {
const withHidden = [
...commands,
makeCmd({
name: 'hidden-cmd',
commandType: 'local',
hidden: true,
supportedModes: ['interactive', 'non_interactive', 'acp'],
}),
];
const result = filterCommandsForMode(withHidden, 'non_interactive');
// filterCommandsForMode passes it through — CommandService.getCommandsForMode removes hidden
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(true);
});
it('returns empty array when no commands match', () => {
const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })];
expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]);
});
});