fix(cli): prefer command name match over alias match regardless of recentScore (#6504)

In compareRankedCommandMatches, recentScore was evaluated before nameVsAlias in the sort chain, causing recently-used alias matches to shadow name matches at the same matchStrength level.

The fix swaps the order so nameVsAlias is checked before recentScore, ensuring that a command matched by its primary name always ranks above an alias match, with recency acting as a tie-breaker only within the same match-type bucket.

Adds a regression test that gives an alias match a recentScore and verifies the name match still ranks first.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
This commit is contained in:
AlexHuang 2026-07-09 00:34:51 +08:00 committed by GitHub
parent 65c0d36be3
commit 74ebb10e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 1 deletions

View file

@ -31,6 +31,10 @@ function useTestHarnessForSlashCompletion(
query: string | null,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
recentCommands?: ReadonlyMap<
string,
{ name: string; usedAt: number; count: number }
>,
) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@ -41,6 +45,7 @@ function useTestHarnessForSlashCompletion(
query,
slashCommands,
commandContext,
recentCommands,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
@ -169,4 +174,53 @@ describe('useSlashCompletion integration', () => {
expect(resumeIndex).toBeLessThan(clearIndex);
expect(recapIndex).toBeLessThan(clearIndex);
});
it('prefers command name match over alias match even when alias has recentScore', async () => {
// Regression test: recentScore should not shadow nameVsAlias dimension.
// Real-world conflict: /re matches `resume` (name) and `reset` (alias of clear).
// Even if `clear` was used recently (giving it a recentScore > 0), `resume`
// (name match) should still rank first because name-vs-alias is checked before
// recentScore in the sort chain.
const now = Date.now();
const slashCommands = [
createTestCommand({
name: 'clear',
altNames: ['reset', 'new'],
description: 'Clear conversation history',
}),
createTestCommand({
name: 'resume',
altNames: ['continue'],
description: 'Resume a previous session',
}),
];
// Give `clear` a recentScore to simulate it was used recently
const recentCommands = new Map([
['clear', { name: 'clear', usedAt: now, count: 1 }],
]);
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/re',
slashCommands,
mockCommandContext,
recentCommands,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(1);
});
// resume is a name match; reset is an alias match.
// Even with recentScore boosting `clear`, name match must win.
const names = result.current.suggestions.map((s) => s.value);
const resumeIndex = names.indexOf('resume');
const clearIndex = names.indexOf('clear');
expect(resumeIndex).toBeGreaterThanOrEqual(0);
expect(clearIndex).toBeGreaterThanOrEqual(0);
expect(resumeIndex).toBeLessThan(clearIndex);
});
});

View file

@ -247,8 +247,8 @@ function compareRankedCommandMatches(
return (
right.matchStrength - left.matchStrength ||
right.completionPriority - left.completionPriority ||
right.recentScore - left.recentScore ||
nameVsAlias ||
right.recentScore - left.recentScore ||
right.score - left.score ||
left.start - right.start ||
left.itemLength - right.itemLength ||