Refactor(cli): Move memory add logic to server tool call (#493)

This commit is contained in:
Allen Hutchison 2025-05-23 08:47:19 -07:00 committed by GitHub
parent 70277591c4
commit f8c4276e69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 137 additions and 351 deletions

View File

@ -1,92 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
// afterEach, // Removed as it's not used
type Mocked,
type Mock,
} from 'vitest';
import * as path from 'path';
import { homedir } from 'os';
import * as fs from 'fs/promises';
import { getGlobalMemoryFilePath, addMemoryEntry } from './memoryUtils.js';
import { SETTINGS_DIRECTORY_NAME } from './settings.js';
import {
MemoryTool,
GEMINI_MD_FILENAME,
// MEMORY_SECTION_HEADER, // Removed as it's not used
// getErrorMessage, // Removed as it's not used
} from '@gemini-code/server';
// Mock the entire fs/promises module
vi.mock('fs/promises');
// Mock MemoryTool static method
vi.mock('@gemini-code/server', async (importOriginal) => {
const actual = await importOriginal<typeof import('@gemini-code/server')>();
return {
...actual,
MemoryTool: {
...actual.MemoryTool,
performAddMemoryEntry: vi.fn(),
},
};
});
describe('memoryUtils', () => {
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
});
describe('getGlobalMemoryFilePath', () => {
it('should return the correct global memory file path', () => {
const expectedPath = path.join(
homedir(),
SETTINGS_DIRECTORY_NAME,
GEMINI_MD_FILENAME,
);
expect(getGlobalMemoryFilePath()).toBe(expectedPath);
});
});
describe('addMemoryEntry', () => {
const mockFs = fs as Mocked<typeof fs>; // Type cast for mocked fs
const mockPerformAddMemoryEntry = MemoryTool.performAddMemoryEntry as Mock;
it('should call MemoryTool.performAddMemoryEntry with correct parameters', async () => {
const testText = 'Remember this important fact.';
const expectedFilePath = getGlobalMemoryFilePath();
await addMemoryEntry(testText);
expect(mockPerformAddMemoryEntry).toHaveBeenCalledOnce();
expect(mockPerformAddMemoryEntry).toHaveBeenCalledWith(
testText,
expectedFilePath,
{
readFile: mockFs.readFile,
writeFile: mockFs.writeFile,
mkdir: mockFs.mkdir,
},
);
});
it('should propagate errors from MemoryTool.performAddMemoryEntry', async () => {
const testText = 'This will fail.';
const expectedError = new Error('Failed to add memory entry');
mockPerformAddMemoryEntry.mockRejectedValueOnce(expectedError);
await expect(addMemoryEntry(testText)).rejects.toThrow(expectedError);
});
});
// More tests will be added here
});

View File

@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { homedir } from 'os';
import { SETTINGS_DIRECTORY_NAME } from './settings.js';
import {
// getErrorMessage, // Removed as it's not used
MemoryTool,
GEMINI_MD_FILENAME,
// MEMORY_SECTION_HEADER, // Removed as it's not used
} from '@gemini-code/server';
/**
* Gets the absolute path to the global GEMINI.md file.
*/
export function getGlobalMemoryFilePath(): string {
return path.join(homedir(), SETTINGS_DIRECTORY_NAME, GEMINI_MD_FILENAME);
}
/**
* Adds a new memory entry to the global GEMINI.md file under the specified header.
*/
export async function addMemoryEntry(text: string): Promise<void> {
const filePath = getGlobalMemoryFilePath();
// The performAddMemoryEntry method from MemoryTool will handle its own errors
// and throw an appropriately formatted error if needed.
await MemoryTool.performAddMemoryEntry(text, filePath, {
readFile: fs.readFile,
writeFile: fs.writeFile,
mkdir: fs.mkdir,
});
}

View File

@ -11,6 +11,7 @@ const { mockProcessExit } = vi.hoisted(() => ({
vi.mock('node:process', () => ({ vi.mock('node:process', () => ({
exit: mockProcessExit, exit: mockProcessExit,
cwd: vi.fn(() => '/mock/cwd'), cwd: vi.fn(() => '/mock/cwd'),
env: { ...process.env },
})); }));
vi.mock('node:fs/promises', () => ({ vi.mock('node:fs/promises', () => ({
@ -22,24 +23,20 @@ vi.mock('node:fs/promises', () => ({
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import open from 'open'; import open from 'open';
import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import {
useSlashCommandProcessor,
type SlashCommandActionReturn,
} from './slashCommandProcessor.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import * as memoryUtils from '../../config/memoryUtils.js'; import { type Config } from '@gemini-code/server';
import { type Config, MemoryTool } from '@gemini-code/server';
import * as fsPromises from 'node:fs/promises';
// Import the module for mocking its functions
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
// Mock dependencies
vi.mock('./useShowMemoryCommand.js', () => ({ vi.mock('./useShowMemoryCommand.js', () => ({
SHOW_MEMORY_COMMAND_NAME: '/memory show', SHOW_MEMORY_COMMAND_NAME: '/memory show',
createShowMemoryAction: vi.fn(() => vi.fn()), createShowMemoryAction: vi.fn(() => vi.fn()),
})); }));
// Spy on the static method we want to mock
const performAddMemoryEntrySpy = vi.spyOn(MemoryTool, 'performAddMemoryEntry');
vi.mock('open', () => ({ vi.mock('open', () => ({
default: vi.fn(), default: vi.fn(),
})); }));
@ -65,29 +62,16 @@ describe('useSlashCommandProcessor', () => {
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined); mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
mockConfig = { mockConfig = {
getDebugMode: vi.fn(() => false), getDebugMode: vi.fn(() => false),
getSandbox: vi.fn(() => 'test-sandbox'), // Added mock getSandbox: vi.fn(() => 'test-sandbox'),
getModel: vi.fn(() => 'test-model'), // Added mock getModel: vi.fn(() => 'test-model'),
} as unknown as Config; } as unknown as Config;
mockCorgiMode = vi.fn(); mockCorgiMode = vi.fn();
// Clear mocks for fsPromises if they were used directly or indirectly
vi.mocked(fsPromises.readFile).mockClear();
vi.mocked(fsPromises.writeFile).mockClear();
vi.mocked(fsPromises.mkdir).mockClear();
performAddMemoryEntrySpy.mockReset();
(open as Mock).mockClear(); (open as Mock).mockClear();
// vi.spyOn(memoryUtils, 'deleteLastMemoryEntry').mockImplementation(vi.fn());
// vi.spyOn(memoryUtils, 'deleteAllAddedMemoryEntries').mockImplementation(
// vi.fn(),
// );
// vi.mocked(memoryUtils.deleteLastMemoryEntry).mockClear();
// vi.mocked(memoryUtils.deleteAllAddedMemoryEntries).mockClear();
mockProcessExit.mockClear(); mockProcessExit.mockClear();
(ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear(); (ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear();
mockPerformMemoryRefresh.mockClear(); mockPerformMemoryRefresh.mockClear();
process.env = { ...globalThis.process.env };
}); });
const getProcessor = () => { const getProcessor = () => {
@ -109,118 +93,97 @@ describe('useSlashCommandProcessor', () => {
}; };
describe('/memory add', () => { describe('/memory add', () => {
it('should call MemoryTool.performAddMemoryEntry and refresh on valid input', async () => { it('should return tool scheduling info on valid input', async () => {
performAddMemoryEntrySpy.mockResolvedValue(undefined);
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
const fact = 'Remember this fact'; const fact = 'Remember this fact';
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand(`/memory add ${fact}`); commandResult = handleSlashCommand(`/memory add ${fact}`);
}); });
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenNthCalledWith(
1, 1, // User message
expect.objectContaining({ expect.objectContaining({
type: MessageType.USER, type: MessageType.USER,
text: `/memory add ${fact}`, text: `/memory add ${fact}`,
}), }),
expect.any(Number), expect.any(Number),
); );
expect(performAddMemoryEntrySpy).toHaveBeenCalledWith(
fact,
memoryUtils.getGlobalMemoryFilePath(), // Ensure this path is correct
{
readFile: fsPromises.readFile,
writeFile: fsPromises.writeFile,
mkdir: fsPromises.mkdir,
},
);
expect(mockPerformMemoryRefresh).toHaveBeenCalled();
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenNthCalledWith(
2, 2, // Info message about attempting to save
expect.objectContaining({ expect.objectContaining({
type: MessageType.INFO, type: MessageType.INFO,
text: `Successfully added to memory: "${fact}"`, text: `Attempting to save to memory: "${fact}"`,
}), }),
expect.any(Number), expect.any(Number),
); );
expect(commandResult).toEqual({
shouldScheduleTool: true,
toolName: 'save_memory',
toolArgs: { fact },
});
// performMemoryRefresh is no longer called directly here
expect(mockPerformMemoryRefresh).not.toHaveBeenCalled();
}); });
it('should show usage error if no text is provided', async () => { it('should show usage error and return true if no text is provided', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/memory add '); commandResult = handleSlashCommand('/memory add ');
}); });
expect(performAddMemoryEntrySpy).not.toHaveBeenCalled();
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenNthCalledWith(
2, 2, // After user message
expect.objectContaining({ expect.objectContaining({
type: MessageType.ERROR, type: MessageType.ERROR,
text: 'Usage: /memory add <text to remember>', text: 'Usage: /memory add <text to remember>',
}), }),
expect.any(Number), expect.any(Number),
); );
}); expect(commandResult).toBe(true); // Command was handled (by showing an error)
it('should handle error from MemoryTool.performAddMemoryEntry', async () => {
const fact = 'Another fact';
performAddMemoryEntrySpy.mockRejectedValue(
new Error('[MemoryTool] Failed to add memory entry: Disk full'),
);
const { handleSlashCommand } = getProcessor();
await act(async () => {
handleSlashCommand(`/memory add ${fact}`);
});
expect(performAddMemoryEntrySpy).toHaveBeenCalledWith(
fact,
memoryUtils.getGlobalMemoryFilePath(),
{
readFile: fsPromises.readFile,
writeFile: fsPromises.writeFile,
mkdir: fsPromises.mkdir,
},
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to add memory: [MemoryTool] Failed to add memory entry: Disk full',
}),
expect.any(Number),
);
}); });
}); });
describe('/memory show', () => { describe('/memory show', () => {
it('should call the showMemoryAction', async () => { it('should call the showMemoryAction and return true', async () => {
const mockReturnedShowAction = vi.fn(); const mockReturnedShowAction = vi.fn();
vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue( vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue(
mockReturnedShowAction, mockReturnedShowAction,
); );
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/memory show'); commandResult = handleSlashCommand('/memory show');
}); });
expect( expect(
ShowMemoryCommandModule.createShowMemoryAction, ShowMemoryCommandModule.createShowMemoryAction,
).toHaveBeenCalledWith(mockConfig, expect.any(Function)); ).toHaveBeenCalledWith(mockConfig, expect.any(Function));
expect(mockReturnedShowAction).toHaveBeenCalled(); expect(mockReturnedShowAction).toHaveBeenCalled();
expect(commandResult).toBe(true);
}); });
}); });
describe('/memory refresh', () => { describe('/memory refresh', () => {
it('should call performMemoryRefresh', async () => { it('should call performMemoryRefresh and return true', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/memory refresh'); commandResult = handleSlashCommand('/memory refresh');
}); });
expect(mockPerformMemoryRefresh).toHaveBeenCalled(); expect(mockPerformMemoryRefresh).toHaveBeenCalled();
expect(commandResult).toBe(true);
}); });
}); });
describe('Unknown /memory subcommand', () => { describe('Unknown /memory subcommand', () => {
it('should show an error for unknown /memory subcommand', async () => { it('should show an error for unknown /memory subcommand and return true', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/memory foobar'); commandResult = handleSlashCommand('/memory foobar');
}); });
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenNthCalledWith(
2, 2,
@ -230,20 +193,33 @@ describe('useSlashCommandProcessor', () => {
}), }),
expect.any(Number), expect.any(Number),
); );
expect(commandResult).toBe(true);
}); });
}); });
describe('Other commands', () => { describe('Other commands', () => {
it('/help should open help', async () => { it('/help should open help and return true', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/help'); commandResult = handleSlashCommand('/help');
}); });
expect(mockSetShowHelp).toHaveBeenCalledWith(true); expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(commandResult).toBe(true);
}); });
}); });
describe('/bug command', () => { describe('/bug command', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
const getExpectedUrl = ( const getExpectedUrl = (
description?: string, description?: string,
sandboxEnvVar?: string, sandboxEnvVar?: string,
@ -257,7 +233,7 @@ describe('useSlashCommandProcessor', () => {
} else if (sandboxEnvVar === 'sandbox-exec') { } else if (sandboxEnvVar === 'sandbox-exec') {
sandboxEnvStr = `sandbox-exec (${seatbeltProfileVar || 'unknown'})`; sandboxEnvStr = `sandbox-exec (${seatbeltProfileVar || 'unknown'})`;
} }
const modelVersion = 'test-model'; // From mockConfig const modelVersion = 'test-model';
const diagnosticInfo = ` const diagnosticInfo = `
## Describe the bug ## Describe the bug
@ -281,7 +257,7 @@ Add any other context about the problem here.
return url; return url;
}; };
it('should call open with the correct GitHub issue URL', async () => { it('should call open with the correct GitHub issue URL and return true', async () => {
process.env.SANDBOX = 'gemini-sandbox'; process.env.SANDBOX = 'gemini-sandbox';
process.env.SEATBELT_PROFILE = 'test_profile'; process.env.SEATBELT_PROFILE = 'test_profile';
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
@ -291,112 +267,23 @@ Add any other context about the problem here.
process.env.SANDBOX, process.env.SANDBOX,
process.env.SEATBELT_PROFILE, process.env.SEATBELT_PROFILE,
); );
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand(`/bug ${bugDescription}`); commandResult = handleSlashCommand(`/bug ${bugDescription}`);
}); });
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenCalledTimes(2);
1, // User command
expect.objectContaining({
type: MessageType.USER,
text: `/bug ${bugDescription}`,
}),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Info message
expect.objectContaining({
type: MessageType.INFO,
text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`,
}),
expect.any(Number), // Timestamps are numbers from Date.now()
);
expect(open).toHaveBeenCalledWith(expectedUrl); expect(open).toHaveBeenCalledWith(expectedUrl);
delete process.env.SANDBOX; expect(commandResult).toBe(true);
delete process.env.SEATBELT_PROFILE;
});
it('should open the generic issue page if no bug description is provided', async () => {
process.env.SANDBOX = 'sandbox-exec';
process.env.SEATBELT_PROFILE = 'minimal';
const { handleSlashCommand } = getProcessor();
const expectedUrl = getExpectedUrl(
undefined,
process.env.SANDBOX,
process.env.SEATBELT_PROFILE,
);
await act(async () => {
handleSlashCommand('/bug ');
});
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(mockAddItem).toHaveBeenNthCalledWith(
1, // User command
expect.objectContaining({
type: MessageType.USER,
text: '/bug', // Ensure this matches the input
}),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Info message
expect.objectContaining({
type: MessageType.INFO,
text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`,
}),
expect.any(Number), // Timestamps are numbers from Date.now()
);
delete process.env.SANDBOX;
delete process.env.SEATBELT_PROFILE;
});
it('should handle errors when open fails', async () => {
// Test with no SANDBOX env var
delete process.env.SANDBOX;
delete process.env.SEATBELT_PROFILE;
const { handleSlashCommand } = getProcessor();
const bugDescription = 'Another bug';
const expectedUrl = getExpectedUrl(bugDescription);
const openError = new Error('Failed to open browser');
(open as Mock).mockRejectedValue(openError);
await act(async () => {
handleSlashCommand(`/bug ${bugDescription}`);
});
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(mockAddItem).toHaveBeenNthCalledWith(
1, // User command
expect.objectContaining({
type: MessageType.USER,
text: `/bug ${bugDescription}`,
}),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Info message before open attempt
expect.objectContaining({
type: MessageType.INFO,
text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`,
}),
expect.any(Number), // Timestamps are numbers from Date.now()
);
expect(mockAddItem).toHaveBeenNthCalledWith(
3, // Error message after open fails
expect.objectContaining({
type: MessageType.ERROR,
text: `Could not open URL in browser: ${openError.message}`,
}),
expect.any(Number), // Timestamps are numbers from Date.now()
);
}); });
}); });
describe('Unknown command', () => { describe('Unknown command', () => {
it('should show an error for a general unknown command', async () => { it('should show an error and return true for a general unknown command', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => { await act(async () => {
handleSlashCommand('/unknowncommand'); commandResult = handleSlashCommand('/unknowncommand');
}); });
expect(mockAddItem).toHaveBeenNthCalledWith( expect(mockAddItem).toHaveBeenNthCalledWith(
2, 2,
@ -406,6 +293,7 @@ Add any other context about the problem here.
}), }),
expect.any(Number), expect.any(Number),
); );
expect(commandResult).toBe(true);
}); });
}); });
}); });

View File

@ -11,13 +11,23 @@ import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { Config } from '@gemini-code/server'; import { Config } from '@gemini-code/server';
import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
import { createShowMemoryAction } from './useShowMemoryCommand.js'; import { createShowMemoryAction } from './useShowMemoryCommand.js';
import { addMemoryEntry } from '../../config/memoryUtils.js';
export interface SlashCommandActionReturn {
shouldScheduleTool?: boolean;
toolName?: string;
toolArgs?: Record<string, unknown>;
message?: string; // For simple messages or errors
}
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
altName?: string; altName?: string;
description?: string; description?: string;
action: (mainCommand: string, subCommand?: string, args?: string) => void; action: (
mainCommand: string,
subCommand?: string,
args?: string,
) => void | SlashCommandActionReturn; // Action can now return this object
} }
/** /**
@ -37,9 +47,8 @@ export const useSlashCommandProcessor = (
) => { ) => {
const addMessage = useCallback( const addMessage = useCallback(
(message: Message) => { (message: Message) => {
// Convert Message to HistoryItemWithoutId
const historyItemContent: HistoryItemWithoutId = { const historyItemContent: HistoryItemWithoutId = {
type: message.type, // MessageType enum should be compatible with HistoryItemWithoutId string literal types type: message.type,
text: message.content, text: message.content,
}; };
addItem(historyItemContent, message.timestamp.getTime()); addItem(historyItemContent, message.timestamp.getTime());
@ -53,7 +62,11 @@ export const useSlashCommandProcessor = (
}, [config, addMessage]); }, [config, addMessage]);
const addMemoryAction = useCallback( const addMemoryAction = useCallback(
async (_mainCommand: string, _subCommand?: string, args?: string) => { (
_mainCommand: string,
_subCommand?: string,
args?: string,
): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') { if (!args || args.trim() === '') {
addMessage({ addMessage({
type: MessageType.ERROR, type: MessageType.ERROR,
@ -62,24 +75,20 @@ export const useSlashCommandProcessor = (
}); });
return; return;
} }
try { // UI feedback for attempting to schedule
await addMemoryEntry(args); addMessage({
addMessage({ type: MessageType.INFO,
type: MessageType.INFO, content: `Attempting to save to memory: "${args.trim()}"`,
content: `Successfully added to memory: "${args}"`, timestamp: new Date(),
timestamp: new Date(), });
}); // Return info for scheduling the tool call
await performMemoryRefresh(); // Refresh memory to reflect changes return {
} catch (e) { shouldScheduleTool: true,
const errorMessage = e instanceof Error ? e.message : String(e); toolName: 'save_memory',
addMessage({ toolArgs: { fact: args.trim() },
type: MessageType.ERROR, };
content: `Failed to add memory: ${errorMessage}`,
timestamp: new Date(),
});
}
}, },
[addMessage, performMemoryRefresh], [addMessage],
); );
const slashCommands: SlashCommand[] = useMemo( const slashCommands: SlashCommand[] = useMemo(
@ -118,19 +127,19 @@ export const useSlashCommandProcessor = (
switch (subCommand) { switch (subCommand) {
case 'show': case 'show':
showMemoryAction(); showMemoryAction();
break; return; // Explicitly return void
case 'refresh': case 'refresh':
performMemoryRefresh(); performMemoryRefresh();
break; return; // Explicitly return void
case 'add': case 'add':
addMemoryAction(mainCommand, subCommand, args); return addMemoryAction(mainCommand, subCommand, args); // Return the object
break;
default: default:
addMessage({ addMessage({
type: MessageType.ERROR, type: MessageType.ERROR,
content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`,
timestamp: new Date(), timestamp: new Date(),
}); });
return; // Explicitly return void
} }
}, },
}, },
@ -187,7 +196,6 @@ Add any other context about the problem here.
content: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, content: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`,
timestamp: new Date(), timestamp: new Date(),
}); });
// Open the URL in the default browser
(async () => { (async () => {
try { try {
await open(bugReportUrl); await open(bugReportUrl);
@ -203,7 +211,6 @@ Add any other context about the problem here.
})(); })();
}, },
}, },
{ {
name: 'quit', name: 'quit',
altName: 'exit', altName: 'exit',
@ -225,25 +232,21 @@ Add any other context about the problem here.
addMemoryAction, addMemoryAction,
addMessage, addMessage,
toggleCorgiMode, toggleCorgiMode,
config, // Added config to dependency array config,
cliVersion, cliVersion,
], ],
); );
const handleSlashCommand = useCallback( const handleSlashCommand = useCallback(
(rawQuery: PartListUnion): boolean => { (rawQuery: PartListUnion): SlashCommandActionReturn | boolean => {
if (typeof rawQuery !== 'string') { if (typeof rawQuery !== 'string') {
return false; return false;
} }
const trimmed = rawQuery.trim(); const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false; return false;
} }
const userMessageTimestamp = Date.now(); const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
let subCommand: string | undefined; let subCommand: string | undefined;
@ -251,9 +254,8 @@ Add any other context about the problem here.
const commandToMatch = (() => { const commandToMatch = (() => {
if (trimmed.startsWith('?')) { if (trimmed.startsWith('?')) {
return 'help'; // No subCommand or args for '?' acting as help return 'help';
} }
// For other slash commands like /memory add foo
const parts = trimmed.substring(1).trim().split(/\s+/); const parts = trimmed.substring(1).trim().split(/\s+/);
if (parts.length > 1) { if (parts.length > 1) {
subCommand = parts[1]; subCommand = parts[1];
@ -261,15 +263,21 @@ Add any other context about the problem here.
if (parts.length > 2) { if (parts.length > 2) {
args = parts.slice(2).join(' '); args = parts.slice(2).join(' ');
} }
return parts[0]; // This is the main command name return parts[0];
})(); })();
const mainCommand = commandToMatch; const mainCommand = commandToMatch;
for (const cmd of slashCommands) { for (const cmd of slashCommands) {
if (mainCommand === cmd.name || mainCommand === cmd.altName) { if (mainCommand === cmd.name || mainCommand === cmd.altName) {
cmd.action(mainCommand, subCommand, args); const actionResult = cmd.action(mainCommand, subCommand, args);
return true; if (
typeof actionResult === 'object' &&
actionResult?.shouldScheduleTool
) {
return actionResult; // Return the object for useGeminiStream
}
return true; // Command was handled, but no tool to schedule
} }
} }
@ -278,8 +286,7 @@ Add any other context about the problem here.
content: `Unknown command: ${trimmed}`, content: `Unknown command: ${trimmed}`,
timestamp: new Date(), timestamp: new Date(),
}); });
return true; // Indicate command was processed (even if unknown)
return true;
}, },
[addItem, slashCommands, addMessage], [addItem, slashCommands, addMessage],
); );

View File

@ -57,7 +57,9 @@ export const useGeminiStream = (
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>, setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
config: Config, config: Config,
onDebugMessage: (message: string) => void, onDebugMessage: (message: string) => void,
handleSlashCommand: (cmd: PartListUnion) => boolean, handleSlashCommand: (
cmd: PartListUnion,
) => import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean,
shellModeActive: boolean, shellModeActive: boolean,
) => { ) => {
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
@ -138,9 +140,27 @@ export const useGeminiStream = (
await logger?.logMessage(MessageSenderType.USER, trimmedQuery); await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
// Handle UI-only commands first // Handle UI-only commands first
if (handleSlashCommand(trimmedQuery)) { const slashCommandResult = handleSlashCommand(trimmedQuery);
if (typeof slashCommandResult === 'boolean' && slashCommandResult) {
// Command was handled, and it doesn't require a tool call from here
return { queryToSend: null, shouldProceed: false }; return { queryToSend: null, shouldProceed: false };
} else if (
typeof slashCommandResult === 'object' &&
slashCommandResult.shouldScheduleTool
) {
// Slash command wants to schedule a tool call (e.g., /memory add)
const { toolName, toolArgs } = slashCommandResult;
if (toolName && toolArgs) {
const toolCallRequest: ToolCallRequestInfo = {
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: toolName,
args: toolArgs,
};
schedule([toolCallRequest]); // schedule expects an array or single object
}
return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool
} }
if (shellModeActive && handleShellCommand(trimmedQuery)) { if (shellModeActive && handleShellCommand(trimmedQuery)) {
return { queryToSend: null, shouldProceed: false }; return { queryToSend: null, shouldProceed: false };
} }