|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; |
|
|
import { |
|
|
MemoryTool, |
|
|
setGeminiMdFilename, |
|
|
getCurrentGeminiMdFilename, |
|
|
getAllGeminiMdFilenames, |
|
|
DEFAULT_CONTEXT_FILENAME, |
|
|
} from './memoryTool.js'; |
|
|
import * as fs from 'fs/promises'; |
|
|
import * as path from 'path'; |
|
|
import * as os from 'os'; |
|
|
|
|
|
|
|
|
vi.mock('fs/promises'); |
|
|
vi.mock('os'); |
|
|
|
|
|
const MEMORY_SECTION_HEADER = '## Gemini Added Memories'; |
|
|
|
|
|
|
|
|
interface FsAdapter { |
|
|
readFile: (path: string, encoding: 'utf-8') => Promise<string>; |
|
|
writeFile: (path: string, data: string, encoding: 'utf-8') => Promise<void>; |
|
|
mkdir: ( |
|
|
path: string, |
|
|
options: { recursive: boolean }, |
|
|
) => Promise<string | undefined>; |
|
|
} |
|
|
|
|
|
describe('MemoryTool', () => { |
|
|
const mockAbortSignal = new AbortController().signal; |
|
|
|
|
|
const mockFsAdapter: { |
|
|
readFile: Mock<FsAdapter['readFile']>; |
|
|
writeFile: Mock<FsAdapter['writeFile']>; |
|
|
mkdir: Mock<FsAdapter['mkdir']>; |
|
|
} = { |
|
|
readFile: vi.fn(), |
|
|
writeFile: vi.fn(), |
|
|
mkdir: vi.fn(), |
|
|
}; |
|
|
|
|
|
beforeEach(() => { |
|
|
vi.mocked(os.homedir).mockReturnValue('/mock/home'); |
|
|
mockFsAdapter.readFile.mockReset(); |
|
|
mockFsAdapter.writeFile.mockReset().mockResolvedValue(undefined); |
|
|
mockFsAdapter.mkdir |
|
|
.mockReset() |
|
|
.mockResolvedValue(undefined as string | undefined); |
|
|
}); |
|
|
|
|
|
afterEach(() => { |
|
|
vi.restoreAllMocks(); |
|
|
|
|
|
setGeminiMdFilename(DEFAULT_CONTEXT_FILENAME); |
|
|
}); |
|
|
|
|
|
describe('setGeminiMdFilename', () => { |
|
|
it('should update currentGeminiMdFilename when a valid new name is provided', () => { |
|
|
const newName = 'CUSTOM_CONTEXT.md'; |
|
|
setGeminiMdFilename(newName); |
|
|
expect(getCurrentGeminiMdFilename()).toBe(newName); |
|
|
}); |
|
|
|
|
|
it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => { |
|
|
const initialName = getCurrentGeminiMdFilename(); |
|
|
setGeminiMdFilename(' '); |
|
|
expect(getCurrentGeminiMdFilename()).toBe(initialName); |
|
|
|
|
|
setGeminiMdFilename(''); |
|
|
expect(getCurrentGeminiMdFilename()).toBe(initialName); |
|
|
}); |
|
|
|
|
|
it('should handle an array of filenames', () => { |
|
|
const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md']; |
|
|
setGeminiMdFilename(newNames); |
|
|
expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); |
|
|
expect(getAllGeminiMdFilenames()).toEqual(newNames); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('performAddMemoryEntry (static method)', () => { |
|
|
const testFilePath = path.join( |
|
|
'/mock/home', |
|
|
'.gemini', |
|
|
DEFAULT_CONTEXT_FILENAME, |
|
|
); |
|
|
|
|
|
it('should create section and save a fact if file does not exist', async () => { |
|
|
mockFsAdapter.readFile.mockRejectedValue({ code: 'ENOENT' }); |
|
|
const fact = 'The sky is blue'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
|
|
|
expect(mockFsAdapter.mkdir).toHaveBeenCalledWith( |
|
|
path.dirname(testFilePath), |
|
|
{ |
|
|
recursive: true, |
|
|
}, |
|
|
); |
|
|
expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
expect(writeFileCall[0]).toBe(testFilePath); |
|
|
const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
expect(writeFileCall[2]).toBe('utf-8'); |
|
|
}); |
|
|
|
|
|
it('should create section and save a fact if file is empty', async () => { |
|
|
mockFsAdapter.readFile.mockResolvedValue(''); |
|
|
const fact = 'The sky is blue'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
}); |
|
|
|
|
|
it('should add a fact to an existing section', async () => { |
|
|
const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n`; |
|
|
mockFsAdapter.readFile.mockResolvedValue(initialContent); |
|
|
const fact = 'New fact 2'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
|
|
|
expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n- ${fact}\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
}); |
|
|
|
|
|
it('should add a fact to an existing empty section', async () => { |
|
|
const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n`; |
|
|
mockFsAdapter.readFile.mockResolvedValue(initialContent); |
|
|
const fact = 'First fact in section'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
|
|
|
expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- ${fact}\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
}); |
|
|
|
|
|
it('should add a fact when other ## sections exist and preserve spacing', async () => { |
|
|
const initialContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n\n## Another Section\nSome other text.`; |
|
|
mockFsAdapter.readFile.mockResolvedValue(initialContent); |
|
|
const fact = 'Fact 2'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
|
|
|
expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
|
|
|
const expectedContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n- ${fact}\n\n## Another Section\nSome other text.\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
}); |
|
|
|
|
|
it('should correctly trim and add a fact that starts with a dash', async () => { |
|
|
mockFsAdapter.readFile.mockResolvedValue(`${MEMORY_SECTION_HEADER}\n`); |
|
|
const fact = '- - My fact with dashes'; |
|
|
await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); |
|
|
const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; |
|
|
const expectedContent = `${MEMORY_SECTION_HEADER}\n- My fact with dashes\n`; |
|
|
expect(writeFileCall[1]).toBe(expectedContent); |
|
|
}); |
|
|
|
|
|
it('should handle error from fsAdapter.writeFile', async () => { |
|
|
mockFsAdapter.readFile.mockResolvedValue(''); |
|
|
mockFsAdapter.writeFile.mockRejectedValue(new Error('Disk full')); |
|
|
const fact = 'This will fail'; |
|
|
await expect( |
|
|
MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter), |
|
|
).rejects.toThrow('[MemoryTool] Failed to add memory entry: Disk full'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('execute (instance method)', () => { |
|
|
let memoryTool: MemoryTool; |
|
|
let performAddMemoryEntrySpy: Mock<typeof MemoryTool.performAddMemoryEntry>; |
|
|
|
|
|
beforeEach(() => { |
|
|
memoryTool = new MemoryTool(); |
|
|
|
|
|
performAddMemoryEntrySpy = vi |
|
|
.spyOn(MemoryTool, 'performAddMemoryEntry') |
|
|
.mockResolvedValue(undefined) as Mock< |
|
|
typeof MemoryTool.performAddMemoryEntry |
|
|
>; |
|
|
|
|
|
}); |
|
|
|
|
|
it('should have correct name, displayName, description, and schema', () => { |
|
|
expect(memoryTool.name).toBe('save_memory'); |
|
|
expect(memoryTool.displayName).toBe('Save Memory'); |
|
|
expect(memoryTool.description).toContain( |
|
|
'Saves a specific piece of information', |
|
|
); |
|
|
expect(memoryTool.schema).toBeDefined(); |
|
|
expect(memoryTool.schema.name).toBe('save_memory'); |
|
|
expect(memoryTool.schema.parameters?.properties?.fact).toBeDefined(); |
|
|
}); |
|
|
|
|
|
it('should call performAddMemoryEntry with correct parameters and return success', async () => { |
|
|
const params = { fact: 'The sky is blue' }; |
|
|
const result = await memoryTool.execute(params, mockAbortSignal); |
|
|
|
|
|
const expectedFilePath = path.join( |
|
|
'/mock/home', |
|
|
'.gemini', |
|
|
getCurrentGeminiMdFilename(), |
|
|
); |
|
|
|
|
|
|
|
|
const expectedFsArgument = { |
|
|
readFile: fs.readFile, |
|
|
writeFile: fs.writeFile, |
|
|
mkdir: fs.mkdir, |
|
|
}; |
|
|
|
|
|
expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( |
|
|
params.fact, |
|
|
expectedFilePath, |
|
|
expectedFsArgument, |
|
|
); |
|
|
const successMessage = `Okay, I've remembered that: "${params.fact}"`; |
|
|
expect(result.llmContent).toBe( |
|
|
JSON.stringify({ success: true, message: successMessage }), |
|
|
); |
|
|
expect(result.returnDisplay).toBe(successMessage); |
|
|
}); |
|
|
|
|
|
it('should return an error if fact is empty', async () => { |
|
|
const params = { fact: ' ' }; |
|
|
const result = await memoryTool.execute(params, mockAbortSignal); |
|
|
const errorMessage = 'Parameter "fact" must be a non-empty string.'; |
|
|
|
|
|
expect(performAddMemoryEntrySpy).not.toHaveBeenCalled(); |
|
|
expect(result.llmContent).toBe( |
|
|
JSON.stringify({ success: false, error: errorMessage }), |
|
|
); |
|
|
expect(result.returnDisplay).toBe(`Error: ${errorMessage}`); |
|
|
}); |
|
|
|
|
|
it('should handle errors from performAddMemoryEntry', async () => { |
|
|
const params = { fact: 'This will fail' }; |
|
|
const underlyingError = new Error( |
|
|
'[MemoryTool] Failed to add memory entry: Disk full', |
|
|
); |
|
|
performAddMemoryEntrySpy.mockRejectedValue(underlyingError); |
|
|
|
|
|
const result = await memoryTool.execute(params, mockAbortSignal); |
|
|
|
|
|
expect(result.llmContent).toBe( |
|
|
JSON.stringify({ |
|
|
success: false, |
|
|
error: `Failed to save memory. Detail: ${underlyingError.message}`, |
|
|
}), |
|
|
); |
|
|
expect(result.returnDisplay).toBe( |
|
|
`Error saving memory: ${underlyingError.message}`, |
|
|
); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|