diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 257 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 38 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.integration.test.ts | 29 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.test.ts | 100 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 59 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.ts | 42 |
6 files changed, 437 insertions, 88 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 32a6810e..84eeb033 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -14,10 +14,17 @@ vi.mock('node:process', () => ({ }, })); -const mockLoadCommands = vi.fn(); +const mockBuiltinLoadCommands = vi.fn(); vi.mock('../../services/BuiltinCommandLoader.js', () => ({ BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ - loadCommands: mockLoadCommands, + loadCommands: mockBuiltinLoadCommands, + })), +})); + +const mockFileLoadCommands = vi.fn(); +vi.mock('../../services/FileCommandLoader.js', () => ({ + FileCommandLoader: vi.fn().mockImplementation(() => ({ + loadCommands: mockFileLoadCommands, })), })); @@ -28,11 +35,22 @@ vi.mock('../contexts/SessionContext.js', () => ({ import { act, renderHook, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; -import { SlashCommand } from '../commands/types.js'; +import { CommandKind, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; import { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; + +const createTestCommand = ( + overrides: Partial<SlashCommand>, + kind: CommandKind = CommandKind.BUILT_IN, +): SlashCommand => ({ + name: 'test', + description: 'a test command', + kind, + ...overrides, +}); describe('useSlashCommandProcessor', () => { const mockAddItem = vi.fn(); @@ -55,11 +73,17 @@ describe('useSlashCommandProcessor', () => { beforeEach(() => { vi.clearAllMocks(); (vi.mocked(BuiltinCommandLoader) as Mock).mockClear(); - mockLoadCommands.mockResolvedValue([]); + mockBuiltinLoadCommands.mockResolvedValue([]); + mockFileLoadCommands.mockResolvedValue([]); }); - const setupProcessorHook = (commands: SlashCommand[] = []) => { - mockLoadCommands.mockResolvedValue(Object.freeze(commands)); + const setupProcessorHook = ( + builtinCommands: SlashCommand[] = [], + fileCommands: SlashCommand[] = [], + ) => { + mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands)); + mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); + const { result } = renderHook(() => useSlashCommandProcessor( mockConfig, @@ -83,18 +107,14 @@ describe('useSlashCommandProcessor', () => { }; describe('Initialization and Command Loading', () => { - it('should initialize CommandService with BuiltinCommandLoader', () => { + it('should initialize CommandService with all required loaders', () => { setupProcessorHook(); - expect(BuiltinCommandLoader).toHaveBeenCalledTimes(1); expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); + expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); }); it('should call loadCommands and populate state after mounting', async () => { - const testCommand: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); await waitFor(() => { @@ -102,15 +122,12 @@ describe('useSlashCommandProcessor', () => { }); expect(result.current.slashCommands[0]?.name).toBe('test'); - expect(mockLoadCommands).toHaveBeenCalledTimes(1); + expect(mockBuiltinLoadCommands).toHaveBeenCalledTimes(1); + expect(mockFileLoadCommands).toHaveBeenCalledTimes(1); }); it('should provide an immutable array of commands to consumers', async () => { - const testCommand: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); await waitFor(() => { @@ -121,13 +138,39 @@ describe('useSlashCommandProcessor', () => { expect(() => { // @ts-expect-error - We are intentionally testing a violation of the readonly type. - commands.push({ - name: 'rogue', - description: 'a rogue command', - kind: 'built-in', - }); + commands.push(createTestCommand({ name: 'rogue' })); }).toThrow(TypeError); }); + + it('should override built-in commands with file-based commands of the same name', async () => { + const builtinAction = vi.fn(); + const fileAction = vi.fn(); + + const builtinCommand = createTestCommand({ + name: 'override', + description: 'builtin', + action: builtinAction, + }); + const fileCommand = createTestCommand( + { name: 'override', description: 'file', action: fileAction }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([builtinCommand], [fileCommand]); + + await waitFor(() => { + // The service should only return one command with the name 'override' + expect(result.current.slashCommands).toHaveLength(1); + }); + + await act(async () => { + await result.current.handleSlashCommand('/override'); + }); + + // Only the file-based command's action should be called. + expect(fileAction).toHaveBeenCalledTimes(1); + expect(builtinAction).not.toHaveBeenCalled(); + }); }); describe('Command Execution Logic', () => { @@ -142,10 +185,10 @@ describe('useSlashCommandProcessor', () => { // Expect 2 calls: one for the user's input, one for the error message. expect(mockAddItem).toHaveBeenCalledTimes(2); expect(mockAddItem).toHaveBeenLastCalledWith( - expect.objectContaining({ + { type: MessageType.ERROR, text: 'Unknown command: /nonexistent', - }), + }, expect.any(Number), ); }); @@ -154,12 +197,12 @@ describe('useSlashCommandProcessor', () => { const parentCommand: SlashCommand = { name: 'parent', description: 'a parent command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'child1', description: 'First child.', - kind: 'built-in', + kind: CommandKind.BUILT_IN, }, ], }; @@ -172,12 +215,12 @@ describe('useSlashCommandProcessor', () => { expect(mockAddItem).toHaveBeenCalledTimes(2); expect(mockAddItem).toHaveBeenLastCalledWith( - expect.objectContaining({ + { type: MessageType.INFO, text: expect.stringContaining( "Command '/parent' requires a subcommand.", ), - }), + }, expect.any(Number), ); }); @@ -187,12 +230,12 @@ describe('useSlashCommandProcessor', () => { const parentCommand: SlashCommand = { name: 'parent', description: 'a parent command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'child', description: 'a child command', - kind: 'built-in', + kind: CommandKind.BUILT_IN, action: childAction, }, ], @@ -222,12 +265,10 @@ describe('useSlashCommandProcessor', () => { describe('Action Result Handling', () => { it('should handle "dialog: help" action', async () => { - const command: SlashCommand = { + const command = createTestCommand({ name: 'helpcmd', - description: 'a help command', - kind: 'built-in', action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }), - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -239,16 +280,14 @@ describe('useSlashCommandProcessor', () => { }); it('should handle "load_history" action', async () => { - const command: SlashCommand = { + const command = createTestCommand({ name: 'load', - description: 'a load command', - kind: 'built-in', action: vi.fn().mockResolvedValue({ type: 'load_history', history: [{ type: MessageType.USER, text: 'old prompt' }], clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }], }), - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -258,7 +297,7 @@ describe('useSlashCommandProcessor', () => { expect(mockClearItems).toHaveBeenCalledTimes(1); expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'user', text: 'old prompt' }), + { type: 'user', text: 'old prompt' }, expect.any(Number), ); }); @@ -270,12 +309,10 @@ describe('useSlashCommandProcessor', () => { const quitAction = vi .fn() .mockResolvedValue({ type: 'quit', messages: [] }); - const command: SlashCommand = { + const command = createTestCommand({ name: 'exit', - description: 'an exit command', - kind: 'built-in', action: quitAction, - }; + }); const result = setupProcessorHook([command]); await waitFor(() => @@ -300,15 +337,43 @@ describe('useSlashCommandProcessor', () => { } }); }); + + it('should handle "submit_prompt" action returned from a file-based command', async () => { + const fileCommand = createTestCommand( + { + name: 'filecmd', + description: 'A command from a file', + action: async () => ({ + type: 'submit_prompt', + content: 'The actual prompt from the TOML file.', + }), + }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([], [fileCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + let actionResult; + await act(async () => { + actionResult = await result.current.handleSlashCommand('/filecmd'); + }); + + expect(actionResult).toEqual({ + type: 'submit_prompt', + content: 'The actual prompt from the TOML file.', + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: '/filecmd' }, + expect.any(Number), + ); + }); }); describe('Command Parsing and Matching', () => { it('should be case-sensitive', async () => { - const command: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - }; + const command = createTestCommand({ name: 'test' }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -319,23 +384,22 @@ describe('useSlashCommandProcessor', () => { // It should fail and call addItem with an error expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ + { type: MessageType.ERROR, text: 'Unknown command: /Test', - }), + }, expect.any(Number), ); }); it('should correctly match an altName', async () => { const action = vi.fn(); - const command: SlashCommand = { + const command = createTestCommand({ name: 'main', altNames: ['alias'], description: 'a command with an alias', - kind: 'built-in', action, - }; + }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -351,12 +415,7 @@ describe('useSlashCommandProcessor', () => { it('should handle extra whitespace around the command', async () => { const action = vi.fn(); - const command: SlashCommand = { - name: 'test', - description: 'a test command', - kind: 'built-in', - action, - }; + const command = createTestCommand({ name: 'test', action }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); @@ -366,6 +425,82 @@ describe('useSlashCommandProcessor', () => { expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args'); }); + + it('should handle `?` as a command prefix', async () => { + const action = vi.fn(); + const command = createTestCommand({ name: 'help', action }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('?help'); + }); + + expect(action).toHaveBeenCalledTimes(1); + }); + }); + + describe('Command Precedence', () => { + it('should prioritize a command with a primary name over a command with a matching alias', async () => { + const quitAction = vi.fn(); + const exitAction = vi.fn(); + + const quitCommand = createTestCommand({ + name: 'quit', + altNames: ['exit'], + action: quitAction, + }); + + const exitCommand = createTestCommand( + { + name: 'exit', + action: exitAction, + }, + CommandKind.FILE, + ); + + // The order of commands in the final loaded array is not guaranteed, + // so the test must work regardless of which comes first. + const result = setupProcessorHook([quitCommand], [exitCommand]); + + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(2); + }); + + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); + + // The action for the command whose primary name is 'exit' should be called. + expect(exitAction).toHaveBeenCalledTimes(1); + // The action for the command that has 'exit' as an alias should NOT be called. + expect(quitAction).not.toHaveBeenCalled(); + }); + + it('should add an overridden command to the history', async () => { + const quitCommand = createTestCommand({ + name: 'quit', + altNames: ['exit'], + action: vi.fn(), + }); + const exitCommand = createTestCommand( + { name: 'exit', action: vi.fn() }, + CommandKind.FILE, + ); + + const result = setupProcessorHook([quitCommand], [exitCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(2)); + + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); + + // It should be added to the history. + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.USER, text: '/exit' }, + expect.any(Number), + ); + }); }); describe('Lifecycle', () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index cdf071b1..48be0470 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -22,6 +22,7 @@ import { LoadedSettings } from '../../config/settings.js'; import { type CommandContext, type SlashCommand } from '../commands/types.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { FileCommandLoader } from '../../services/FileCommandLoader.js'; /** * Hook to define and process slash commands (e.g., /help, /clear). @@ -162,8 +163,10 @@ export const useSlashCommandProcessor = ( useEffect(() => { const controller = new AbortController(); const load = async () => { - // TODO - Add other loaders for custom commands. - const loaders = [new BuiltinCommandLoader(config)]; + const loaders = [ + new BuiltinCommandLoader(config), + new FileCommandLoader(config), + ]; const commandService = await CommandService.create( loaders, controller.signal, @@ -192,12 +195,7 @@ export const useSlashCommandProcessor = ( } const userMessageTimestamp = Date.now(); - if (trimmed !== '/quit' && trimmed !== '/exit') { - addItem( - { type: MessageType.USER, text: trimmed }, - userMessageTimestamp, - ); - } + addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); const parts = trimmed.substring(1).trim().split(/\s+/); const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] @@ -207,9 +205,21 @@ export const useSlashCommandProcessor = ( let pathIndex = 0; for (const part of commandPath) { - const foundCommand = currentCommands.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); + // TODO: For better performance and architectural clarity, this two-pass + // search could be replaced. A more optimal approach would be to + // pre-compute a single lookup map in `CommandService.ts` that resolves + // all name and alias conflicts during the initial loading phase. The + // processor would then perform a single, fast lookup on that map. + + // First pass: check for an exact match on the primary command name. + let foundCommand = currentCommands.find((cmd) => cmd.name === part); + + // Second pass: if no primary name matches, check for an alias. + if (!foundCommand) { + foundCommand = currentCommands.find((cmd) => + cmd.altNames?.includes(part), + ); + } if (foundCommand) { commandToExecute = foundCommand; @@ -290,6 +300,12 @@ export const useSlashCommandProcessor = ( process.exit(0); }, 100); return { type: 'handled' }; + + case 'submit_prompt': + return { + type: 'submit_prompt', + content: result.content, + }; default: { const unhandled: never = result; throw new Error(`Unhandled slash command result: ${unhandled}`); diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts index 02162159..840d2814 100644 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts @@ -10,7 +10,11 @@ import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; import { glob } from 'glob'; -import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + CommandContext, + CommandKind, + SlashCommand, +} from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; interface MockConfig { @@ -43,8 +47,18 @@ describe('useCompletion git-aware filtering integration', () => { const testCwd = '/test/project'; const slashCommands = [ - { name: 'help', description: 'Show help', action: vi.fn() }, - { name: 'clear', description: 'Clear screen', action: vi.fn() }, + { + name: 'help', + description: 'Show help', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, + { + name: 'clear', + description: 'Clear screen', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, ]; // A minimal mock is sufficient for these tests. @@ -56,31 +70,37 @@ describe('useCompletion git-aware filtering integration', () => { altNames: ['?'], description: 'Show help', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'clear', description: 'Clear the screen', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'memory', description: 'Manage memory', + kind: CommandKind.BUILT_IN, // This command is a parent, no action. subCommands: [ { name: 'show', description: 'Show memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'add', description: 'Add to memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, ], @@ -88,15 +108,18 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'chat', description: 'Manage chat history', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'save', description: 'Save chat', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'resume', description: 'Resume a saved chat', + kind: CommandKind.BUILT_IN, action: vi.fn(), // This command provides its own argument completions completion: vi diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index d1b22a88..19671de4 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -12,7 +12,11 @@ import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; import { glob } from 'glob'; -import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + CommandContext, + CommandKind, + SlashCommand, +} from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; // Mock dependencies @@ -69,30 +73,36 @@ describe('useCompletion', () => { altNames: ['?'], description: 'Show help', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'stats', altNames: ['usage'], description: 'check session stats. Usage: /stats [model|tools]', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'clear', description: 'Clear the screen', action: vi.fn(), + kind: CommandKind.BUILT_IN, }, { name: 'memory', description: 'Manage memory', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'show', description: 'Show memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, { name: 'add', description: 'Add to memory', + kind: CommandKind.BUILT_IN, action: vi.fn(), }, ], @@ -100,15 +110,20 @@ describe('useCompletion', () => { { name: 'chat', description: 'Manage chat history', + kind: CommandKind.BUILT_IN, subCommands: [ { name: 'save', description: 'Save chat', + kind: CommandKind.BUILT_IN, + action: vi.fn(), }, { name: 'resume', description: 'Resume a saved chat', + kind: CommandKind.BUILT_IN, + action: vi.fn(), completion: vi.fn().mockResolvedValue(['chat1', 'chat2']), }, @@ -344,6 +359,7 @@ describe('useCompletion', () => { const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({ name: `command${i}`, description: `Command ${i}`, + kind: CommandKind.BUILT_IN, action: vi.fn(), })); @@ -629,6 +645,88 @@ describe('useCompletion', () => { }); }); + describe('Slash command completion with namespaced names', () => { + let commandsWithNamespaces: SlashCommand[]; + + beforeEach(() => { + commandsWithNamespaces = [ + ...mockSlashCommands, + { + name: 'git:commit', + description: 'A namespaced git command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + { + name: 'git:push', + description: 'Another namespaced git command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + { + name: 'docker:build', + description: 'A docker command', + kind: CommandKind.FILE, + action: vi.fn(), + }, + ]; + }); + + it('should suggest a namespaced command based on a partial match', () => { + const { result } = renderHook(() => + useCompletion( + '/git:co', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions).toHaveLength(1); + expect(result.current.suggestions[0].label).toBe('git:commit'); + }); + + it('should suggest all commands within a namespace when the namespace prefix is typed', () => { + const { result } = renderHook(() => + useCompletion( + '/git:', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['git:commit', 'git:push']), + ); + + expect(result.current.suggestions.map((s) => s.label)).not.toContain( + 'docker:build', + ); + }); + + it('should not provide suggestions if the namespaced command is a perfect leaf match', () => { + const { result } = renderHook(() => + useCompletion( + '/git:commit', + testCwd, + true, + commandsWithNamespaces, + mockCommandContext, + mockConfig, + ), + ); + + expect(result.current.showSuggestions).toBe(false); + expect(result.current.suggestions).toHaveLength(0); + }); + }); + describe('File path completion (@-syntax)', () => { beforeEach(() => { vi.mocked(fs.readdir).mockResolvedValue([ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d7fd35c8..02fae607 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1058,6 +1058,65 @@ describe('useGeminiStream', () => { expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made }); }); + + it('should call Gemini with prompt content when slash command returns a `submit_prompt` action', async () => { + const customCommandResult: SlashCommandProcessorResult = { + type: 'submit_prompt', + content: 'This is the actual prompt from the command file.', + }; + mockHandleSlashCommand.mockResolvedValue(customCommandResult); + + const { result, mockSendMessageStream: localMockSendMessageStream } = + renderTestHook(); + + await act(async () => { + await result.current.submitQuery('/my-custom-command'); + }); + + await waitFor(() => { + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/my-custom-command', + ); + + expect(localMockSendMessageStream).not.toHaveBeenCalledWith( + '/my-custom-command', + expect.anything(), + expect.anything(), + ); + + expect(localMockSendMessageStream).toHaveBeenCalledWith( + 'This is the actual prompt from the command file.', + expect.any(AbortSignal), + expect.any(String), + ); + + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + }); + }); + + it('should correctly handle a submit_prompt action with empty content', async () => { + const emptyPromptResult: SlashCommandProcessorResult = { + type: 'submit_prompt', + content: '', + }; + mockHandleSlashCommand.mockResolvedValue(emptyPromptResult); + + const { result, mockSendMessageStream: localMockSendMessageStream } = + renderTestHook(); + + await act(async () => { + await result.current.submitQuery('/emptycmd'); + }); + + await waitFor(() => { + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/emptycmd'); + expect(localMockSendMessageStream).toHaveBeenCalledWith( + '', + expect.any(AbortSignal), + expect.any(String), + ); + }); + }); }); describe('Memory Refresh on save_memory', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 295b5650..456c0fb7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -240,19 +240,37 @@ export const useGeminiStream = ( const slashCommandResult = await handleSlashCommand(trimmedQuery); if (slashCommandResult) { - if (slashCommandResult.type === 'schedule_tool') { - const { toolName, toolArgs } = slashCommandResult; - const toolCallRequest: ToolCallRequestInfo = { - callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, - name: toolName, - args: toolArgs, - isClientInitiated: true, - prompt_id, - }; - scheduleToolCalls([toolCallRequest], abortSignal); - } + switch (slashCommandResult.type) { + case 'schedule_tool': { + const { toolName, toolArgs } = slashCommandResult; + const toolCallRequest: ToolCallRequestInfo = { + callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name: toolName, + args: toolArgs, + isClientInitiated: true, + prompt_id, + }; + scheduleToolCalls([toolCallRequest], abortSignal); + return { queryToSend: null, shouldProceed: false }; + } + case 'submit_prompt': { + localQueryToSendToGemini = slashCommandResult.content; - return { queryToSend: null, shouldProceed: false }; + return { + queryToSend: localQueryToSendToGemini, + shouldProceed: true, + }; + } + case 'handled': { + return { queryToSend: null, shouldProceed: false }; + } + default: { + const unreachable: never = slashCommandResult; + throw new Error( + `Unhandled slash command result type: ${unreachable}`, + ); + } + } } if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) { |
