feat(cli): add fuzzy matching for command suggestions (#6633)

Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
This commit is contained in:
Davor Racic
2025-08-29 20:38:39 +02:00
committed by GitHub
parent 6f91cfa9a3
commit 175fc3bf03
2 changed files with 1119 additions and 193 deletions

View File

@@ -10,9 +10,135 @@ import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useSlashCompletion } from './useSlashCompletion.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import { useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
// Test utility type and helper function for creating test SlashCommands
type TestSlashCommand = Omit<SlashCommand, 'kind'> &
Partial<Pick<SlashCommand, 'kind'>>;
function createTestCommand(command: TestSlashCommand): SlashCommand {
return {
kind: CommandKind.BUILT_IN, // default for tests
...command,
};
}
// Track AsyncFzf constructor calls for cache testing
let asyncFzfConstructorCalls = 0;
const resetConstructorCallCount = () => {
asyncFzfConstructorCalls = 0;
};
const getConstructorCallCount = () => asyncFzfConstructorCalls;
// Centralized fuzzy matching simulation logic
// Note: This is a simplified reimplementation that may diverge from real fzf behavior.
// Integration tests in useSlashCompletion.integration.test.ts use the real fzf library
// to catch any behavioral differences and serve as our "canary in a coal mine."
function simulateFuzzyMatching(items: readonly string[], query: string) {
const results = [];
if (query) {
const lowerQuery = query.toLowerCase();
for (const item of items) {
const lowerItem = item.toLowerCase();
// Exact match gets highest score
if (lowerItem === lowerQuery) {
results.push({
item,
positions: [],
score: 100,
start: 0,
end: item.length,
});
continue;
}
// Prefix match gets high score
if (lowerItem.startsWith(lowerQuery)) {
results.push({
item,
positions: [],
score: 80,
start: 0,
end: query.length,
});
continue;
}
// Fuzzy matching: check if query chars appear in order
let queryIndex = 0;
let score = 0;
for (
let i = 0;
i < lowerItem.length && queryIndex < lowerQuery.length;
i++
) {
if (lowerItem[i] === lowerQuery[queryIndex]) {
queryIndex++;
score += 10 - i; // Earlier matches get higher scores
}
}
// If all query characters were found in order, include this item
if (queryIndex === lowerQuery.length) {
results.push({
item,
positions: [],
score,
start: 0,
end: query.length,
});
}
}
}
// Sort by score descending (better matches first)
results.sort((a, b) => b.score - a.score);
return Promise.resolve(results);
}
// Mock the fzf module to provide a working fuzzy search implementation for tests
vi.mock('fzf', async () => {
const actual = await vi.importActual<typeof import('fzf')>('fzf');
return {
...actual,
AsyncFzf: vi.fn().mockImplementation((items, _options) => {
asyncFzfConstructorCalls++;
return {
find: vi
.fn()
.mockImplementation((query: string) =>
simulateFuzzyMatching(items, query),
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}),
};
});
// Default mock behavior helper - now uses centralized logic
const createDefaultAsyncFzfMock =
() => (items: readonly string[], _options: unknown) => {
asyncFzfConstructorCalls++;
return {
find: vi
.fn()
.mockImplementation((query: string) =>
simulateFuzzyMatching(items, query),
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
// Export test utilities
export {
resetConstructorCallCount,
getConstructorCallCount,
createDefaultAsyncFzfMock,
};
// Test harness to capture the state from the hook's callbacks.
function useTestHarnessForSlashCompletion(
enabled: boolean,
@@ -50,20 +176,26 @@ describe('useSlashCompletion', () => {
describe('Top-Level Commands', () => {
it('should suggest all top-level commands for the root slash', async () => {
const slashCommands = [
{ name: 'help', altNames: ['?'], description: 'Show help' },
{
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
}),
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
{ name: 'clear', description: 'Clear the screen' },
{
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [{ name: 'show', description: 'Show memory' }],
},
{ name: 'chat', description: 'Manage chat history' },
] as unknown as SlashCommand[];
subCommands: [
createTestCommand({ name: 'show', description: 'Show memory' }),
],
}),
createTestCommand({ name: 'chat', description: 'Manage chat history' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -81,8 +213,8 @@ describe('useSlashCompletion', () => {
it('should filter commands based on partial input', async () => {
const slashCommands = [
{ name: 'memory', description: 'Manage memory' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'memory', description: 'Manage memory' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -92,19 +224,21 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
]);
});
});
it('should suggest commands based on partial altNames', async () => {
const slashCommands = [
{
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -114,19 +248,25 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{
label: 'stats',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
},
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'stats',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
},
]);
});
});
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen', action: vi.fn() },
] as unknown as SlashCommand[];
createTestCommand({
name: 'clear',
description: 'Clear the screen',
action: vi.fn(),
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -143,19 +283,19 @@ describe('useSlashCompletion', () => {
'should not suggest commands when altNames is fully typed',
async (query) => {
const mockSlashCommands = [
{
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
action: vi.fn(),
},
{
}),
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
action: vi.fn(),
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -172,8 +312,8 @@ describe('useSlashCompletion', () => {
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -188,8 +328,8 @@ describe('useSlashCompletion', () => {
it('should not provide suggestions for an unknown command', async () => {
const slashCommands = [
{ name: 'help', description: 'Show help' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'help', description: 'Show help' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -206,15 +346,15 @@ describe('useSlashCompletion', () => {
describe('Sub-Commands', () => {
it('should suggest sub-commands for a parent command', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -236,15 +376,15 @@ describe('useSlashCompletion', () => {
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -265,15 +405,15 @@ describe('useSlashCompletion', () => {
it('should filter sub-commands by prefix', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -283,22 +423,24 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' },
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' },
]);
});
});
it('should provide no suggestions for an invalid sub-command', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -327,18 +469,18 @@ describe('useSlashCompletion', () => {
);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -370,18 +512,18 @@ describe('useSlashCompletion', () => {
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -404,18 +546,18 @@ describe('useSlashCompletion', () => {
it('should handle completion function that returns null', async () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: completionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -431,4 +573,474 @@ describe('useSlashCompletion', () => {
});
});
});
describe('Fuzzy Matching', () => {
const fuzzyTestCommands = [
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
}),
createTestCommand({
name: 'history',
description: 'Show command history',
}),
createTestCommand({ name: 'hello', description: 'Hello world command' }),
createTestCommand({
name: 'config',
altNames: ['configure'],
description: 'Configure settings',
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
];
it('should match commands with fuzzy search for partial queries', async () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/he',
fuzzyTestCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toEqual(expect.arrayContaining(['help', 'hello']));
});
it('should handle case-insensitive fuzzy matching', async () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/HeLp',
fuzzyTestCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('help');
});
it('should provide typo-tolerant matching', async () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/hlp',
fuzzyTestCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('help');
});
it('should match against alternative names with fuzzy search', async () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/conf',
fuzzyTestCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('config');
});
it('should fallback to prefix matching when AsyncFzf find fails', async () => {
// Mock console.error to avoid noise in test output
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Import the mocked AsyncFzf
const { AsyncFzf } = await import('fzf');
// Create a failing find method for this specific test
const mockFind = vi
.fn()
.mockRejectedValue(new Error('AsyncFzf find failed'));
// Mock AsyncFzf to return an instance with failing find
vi.mocked(AsyncFzf).mockImplementation(
(_items, _options) =>
({
finder: vi.fn(),
find: mockFind,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const testCommands = [
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'config',
description: 'Configure settings',
}),
createTestCommand({ name: 'chat', description: 'Start chat' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/cle',
testCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// Should still get suggestions via prefix matching fallback
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('clear');
expect(labels).not.toContain('config'); // Doesn't start with 'cle'
expect(labels).not.toContain('chat'); // Doesn't start with 'cle'
// Verify the error was logged
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Fuzzy search - falling back to prefix matching]',
expect.any(Error),
);
});
consoleErrorSpy.mockRestore();
// Reset AsyncFzf mock to default behavior for other tests
vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock());
});
it('should show all commands for empty partial query', async () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
fuzzyTestCommands,
mockCommandContext,
),
);
expect(result.current.suggestions.length).toBe(fuzzyTestCommands.length);
});
it('should handle AsyncFzf errors gracefully and fallback to prefix matching', async () => {
// Mock console.error to avoid noise in test output
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Import the mocked AsyncFzf
const { AsyncFzf } = await import('fzf');
// Create a failing find method for this specific test
const mockFind = vi
.fn()
.mockRejectedValue(new Error('AsyncFzf error in find'));
// Mock AsyncFzf to return an instance with failing find
vi.mocked(AsyncFzf).mockImplementation(
(_items, _options) =>
({
finder: vi.fn(),
find: mockFind,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const testCommands = [
{ name: 'test', description: 'Test command' },
{ name: 'temp', description: 'Temporary command' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/te',
testCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// Should get suggestions via prefix matching fallback
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toEqual(expect.arrayContaining(['test', 'temp']));
// Verify the error was logged
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Fuzzy search - falling back to prefix matching]',
expect.any(Error),
);
});
consoleErrorSpy.mockRestore();
// Reset AsyncFzf mock to default behavior for other tests
vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock());
});
it('should cache AsyncFzf instances for performance', async () => {
// Reset constructor call count and ensure mock is set up correctly
resetConstructorCallCount();
// Import the mocked AsyncFzf
const { AsyncFzf } = await import('fzf');
vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock());
const { result, rerender } = renderHook(
({ query }) =>
useTestHarnessForSlashCompletion(
true,
query,
fuzzyTestCommands,
mockCommandContext,
),
{ initialProps: { query: '/he' } },
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const firstResults = result.current.suggestions.map((s) => s.label);
const callCountAfterFirst = getConstructorCallCount();
expect(callCountAfterFirst).toBeGreaterThan(0);
// Rerender with same query - should use cached instance
rerender({ query: '/he' });
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const secondResults = result.current.suggestions.map((s) => s.label);
const callCountAfterSecond = getConstructorCallCount();
// Should have same number of constructor calls (reused cached instance)
expect(callCountAfterSecond).toBe(callCountAfterFirst);
expect(secondResults).toEqual(firstResults);
// Different query should still use same cached instance for same command set
rerender({ query: '/hel' });
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const thirdCallCount = getConstructorCallCount();
expect(thirdCallCount).toBe(callCountAfterFirst); // Same constructor call count
});
it('should not return duplicate suggestions when query matches both name and altNames', async () => {
const commandsWithAltNames = [
createTestCommand({
name: 'config',
altNames: ['configure', 'conf'],
description: 'Configure settings',
}),
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/con',
commandsWithAltNames,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
const labels = result.current.suggestions.map((s) => s.label);
const uniqueLabels = new Set(labels);
// Should not have duplicates
expect(labels.length).toBe(uniqueLabels.size);
expect(labels).toContain('config');
});
});
describe('Race Condition Handling', () => {
it('should handle rapid input changes without race conditions', async () => {
const mockDelayedCompletion = vi
.fn()
.mockImplementation(
async (_context: CommandContext, partialArg: string) => {
// Simulate network delay with different delays for different inputs
const delay = partialArg.includes('slow') ? 200 : 50;
await new Promise((resolve) => setTimeout(resolve, delay));
return [`suggestion-for-${partialArg}`];
},
);
const slashCommands = [
createTestCommand({
name: 'test',
description: 'Test command',
completion: mockDelayedCompletion,
}),
];
const { result, rerender } = renderHook(
({ query }) =>
useTestHarnessForSlashCompletion(
true,
query,
slashCommands,
mockCommandContext,
),
{ initialProps: { query: '/test slowquery' } },
);
// Quickly change to a faster query
rerender({ query: '/test fastquery' });
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// Should show suggestions for the latest query only
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('suggestion-for-fastquery');
expect(labels).not.toContain('suggestion-for-slowquery');
});
it('should not update suggestions if component unmounts during async operation', async () => {
let resolveCompletion: (value: string[]) => void;
const mockCompletion = vi.fn().mockImplementation(
async () =>
new Promise<string[]>((resolve) => {
resolveCompletion = resolve;
}),
);
const slashCommands = [
createTestCommand({
name: 'test',
description: 'Test command',
completion: mockCompletion,
}),
];
const { unmount } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/test query',
slashCommands,
mockCommandContext,
),
);
// Start the async operation
await waitFor(() => {
expect(mockCompletion).toHaveBeenCalled();
});
// Unmount before completion resolves
unmount();
// Now resolve the completion
resolveCompletion!(['late-suggestion']);
// Wait a bit to ensure any pending updates would have been processed
await new Promise((resolve) => setTimeout(resolve, 100));
// Since the component is unmounted, suggestions should remain empty
// and no state update errors should occur
expect(true).toBe(true); // Test passes if no errors are thrown
});
});
describe('Error Logging', () => {
it('should log errors to the console', async () => {
// Mock console.error to capture log calls
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Import the mocked AsyncFzf
const { AsyncFzf } = await import('fzf');
// Create a failing find method with error containing sensitive-looking data
const sensitiveError = new Error(
'Database connection failed: user=admin, pass=secret123',
);
const mockFind = vi.fn().mockRejectedValue(sensitiveError);
// Mock AsyncFzf to return an instance with failing find
vi.mocked(AsyncFzf).mockImplementation(
(_items, _options) =>
({
find: mockFind,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const testCommands = [
createTestCommand({ name: 'test', description: 'Test command' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/test',
testCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
// Should get fallback suggestions
const labels = result.current.suggestions.map((s) => s.label);
expect(labels).toContain('test');
// Verify error logging occurred
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Fuzzy search - falling back to prefix matching]',
sensitiveError,
);
});
consoleErrorSpy.mockRestore();
// Reset AsyncFzf mock to default behavior
vi.mocked(AsyncFzf).mockImplementation(createDefaultAsyncFzfMock());
});
});
});