| | |
| | |
| | |
| | |
| | |
| |
|
| | 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}`, |
| | ); |
| | }); |
| | }); |
| | }); |
| |
|