Refactor(cli): Move memory add logic to server tool call (#493)
This commit is contained in:
parent
70277591c4
commit
f8c4276e69
|
@ -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
|
|
||||||
});
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue