| import { Response } from 'express'; |
| import { Providers } from '@librechat/agents'; |
| import { Tools } from 'librechat-data-provider'; |
| import type { MemoryArtifact } from 'librechat-data-provider'; |
| import { createMemoryTool, processMemory } from '../memory'; |
|
|
| |
| jest.mock('winston', () => ({ |
| createLogger: jest.fn(() => ({ |
| debug: jest.fn(), |
| warn: jest.fn(), |
| error: jest.fn(), |
| })), |
| format: { |
| combine: jest.fn(), |
| colorize: jest.fn(), |
| simple: jest.fn(), |
| }, |
| transports: { |
| Console: jest.fn(), |
| }, |
| })); |
|
|
| |
| jest.mock('~/utils', () => ({ |
| Tokenizer: { |
| getTokenCount: jest.fn((text: string) => text.length), |
| }, |
| })); |
|
|
| |
| jest.mock('@librechat/agents', () => ({ |
| ...jest.requireActual('@librechat/agents'), |
| Run: { |
| create: jest.fn(), |
| }, |
| Providers: { |
| OPENAI: 'openai', |
| ANTHROPIC: 'anthropic', |
| AZURE: 'azure', |
| }, |
| GraphEvents: { |
| TOOL_END: 'tool_end', |
| }, |
| })); |
|
|
| describe('createMemoryTool', () => { |
| let mockSetMemory: jest.Mock; |
|
|
| beforeEach(() => { |
| jest.clearAllMocks(); |
| mockSetMemory = jest.fn().mockResolvedValue({ ok: true }); |
| }); |
|
|
| |
| describe('overflow handling', () => { |
| it('should return error artifact when memory is already overflowing', async () => { |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| tokenLimit: 100, |
| totalTokens: 150, |
| }); |
|
|
| |
| const result = await tool.func({ key: 'test', value: 'new memory' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Memory storage exceeded. Cannot save new memories.'); |
|
|
| const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>; |
| expect(artifacts[Tools.memory]).toBeDefined(); |
| expect(artifacts[Tools.memory].type).toBe('error'); |
| expect(artifacts[Tools.memory].key).toBe('system'); |
|
|
| const errorData = JSON.parse(artifacts[Tools.memory].value as string); |
| expect(errorData).toEqual({ |
| errorType: 'already_exceeded', |
| tokenCount: 50, |
| totalTokens: 150, |
| tokenLimit: 100, |
| }); |
|
|
| expect(mockSetMemory).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should return error artifact when new memory would exceed limit', async () => { |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| tokenLimit: 100, |
| totalTokens: 80, |
| }); |
|
|
| |
| const result = await tool.func({ key: 'test', value: 'This is a 20 char str' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Memory storage would exceed limit. Cannot save this memory.'); |
|
|
| const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>; |
| expect(artifacts[Tools.memory]).toBeDefined(); |
| expect(artifacts[Tools.memory].type).toBe('error'); |
| expect(artifacts[Tools.memory].key).toBe('system'); |
|
|
| const errorData = JSON.parse(artifacts[Tools.memory].value as string); |
| expect(errorData).toEqual({ |
| errorType: 'would_exceed', |
| tokenCount: 1, |
| totalTokens: 101, |
| tokenLimit: 100, |
| }); |
|
|
| expect(mockSetMemory).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should successfully save memory when below limit', async () => { |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| tokenLimit: 100, |
| totalTokens: 50, |
| }); |
|
|
| const result = await tool.func({ key: 'test', value: 'small memory' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Memory set for key "test" (12 tokens)'); |
|
|
| const artifacts = result[1] as Record<Tools.memory, MemoryArtifact>; |
| expect(artifacts[Tools.memory]).toBeDefined(); |
| expect(artifacts[Tools.memory].type).toBe('update'); |
| expect(artifacts[Tools.memory].key).toBe('test'); |
| expect(artifacts[Tools.memory].value).toBe('small memory'); |
|
|
| expect(mockSetMemory).toHaveBeenCalledWith({ |
| userId: 'test-user', |
| key: 'test', |
| value: 'small memory', |
| tokenCount: 12, |
| }); |
| }); |
| }); |
|
|
| |
| describe('basic functionality', () => { |
| it('should validate keys when validKeys is provided', async () => { |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| validKeys: ['allowed', 'keys'], |
| }); |
|
|
| const result = await tool.func({ key: 'invalid', value: 'some value' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Invalid key "invalid". Must be one of: allowed, keys'); |
| expect(result[1]).toBeUndefined(); |
| expect(mockSetMemory).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should handle setMemory failure', async () => { |
| mockSetMemory.mockResolvedValue({ ok: false }); |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| }); |
|
|
| const result = await tool.func({ key: 'test', value: 'some value' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Failed to set memory for key "test"'); |
| expect(result[1]).toBeUndefined(); |
| }); |
|
|
| it('should handle exceptions', async () => { |
| mockSetMemory.mockRejectedValue(new Error('DB error')); |
| const tool = createMemoryTool({ |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| }); |
|
|
| const result = await tool.func({ key: 'test', value: 'some value' }); |
| expect(result).toHaveLength(2); |
| expect(result[0]).toBe('Error setting memory for key "test"'); |
| expect(result[1]).toBeUndefined(); |
| }); |
| }); |
| }); |
|
|
| describe('processMemory - GPT-5+ handling', () => { |
| let mockSetMemory: jest.Mock; |
| let mockDeleteMemory: jest.Mock; |
| let mockRes: Partial<Response>; |
|
|
| beforeEach(() => { |
| jest.clearAllMocks(); |
| mockSetMemory = jest.fn().mockResolvedValue({ ok: true }); |
| mockDeleteMemory = jest.fn().mockResolvedValue({ ok: true }); |
| mockRes = { |
| headersSent: false, |
| write: jest.fn(), |
| }; |
|
|
| |
| const { Run } = jest.requireMock('@librechat/agents'); |
| (Run.create as jest.Mock).mockResolvedValue({ |
| processStream: jest.fn().mockResolvedValue('Memory processed'), |
| }); |
| }); |
|
|
| it('should remove temperature for GPT-5 models', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model: 'gpt-5', |
| temperature: 0.7, |
| maxTokens: 1000, |
| }, |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-5', |
| modelKwargs: { |
| max_completion_tokens: 1000, |
| }, |
| }), |
| }), |
| }), |
| ); |
|
|
| |
| const callArgs = (Run.create as jest.Mock).mock.calls[0][0]; |
| expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined(); |
| expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined(); |
| }); |
|
|
| it('should handle GPT-5+ models with existing modelKwargs', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model: 'gpt-6', |
| temperature: 0.8, |
| maxTokens: 2000, |
| modelKwargs: { |
| customParam: 'value', |
| }, |
| }, |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-6', |
| modelKwargs: { |
| customParam: 'value', |
| max_completion_tokens: 2000, |
| }, |
| }), |
| }), |
| }), |
| ); |
|
|
| const callArgs = (Run.create as jest.Mock).mock.calls[0][0]; |
| expect(callArgs.graphConfig.llmConfig.temperature).toBeUndefined(); |
| expect(callArgs.graphConfig.llmConfig.maxTokens).toBeUndefined(); |
| }); |
|
|
| it('should not modify non-GPT-5+ models', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model: 'gpt-4', |
| temperature: 0.7, |
| maxTokens: 1000, |
| }, |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-4', |
| temperature: 0.7, |
| maxTokens: 1000, |
| }), |
| }), |
| }), |
| ); |
|
|
| |
| const callArgs = (Run.create as jest.Mock).mock.calls[0][0]; |
| expect(callArgs.graphConfig.llmConfig.modelKwargs).toBeUndefined(); |
| }); |
|
|
| it('should handle various GPT-5+ model formats', async () => { |
| const testCases = [ |
| { model: 'gpt-5', shouldTransform: true }, |
| { model: 'gpt-5-turbo', shouldTransform: true }, |
| { model: 'gpt-7-preview', shouldTransform: true }, |
| { model: 'gpt-9', shouldTransform: true }, |
| { model: 'gpt-4o', shouldTransform: false }, |
| { model: 'gpt-3.5-turbo', shouldTransform: false }, |
| ]; |
|
|
| for (const { model, shouldTransform } of testCases) { |
| jest.clearAllMocks(); |
| const { Run } = jest.requireMock('@librechat/agents'); |
| (Run.create as jest.Mock).mockResolvedValue({ |
| processStream: jest.fn().mockResolvedValue('Memory processed'), |
| }); |
|
|
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model, |
| temperature: 0.5, |
| maxTokens: 1500, |
| }, |
| }); |
|
|
| const callArgs = (Run.create as jest.Mock).mock.calls[0][0]; |
| const llmConfig = callArgs.graphConfig.llmConfig; |
|
|
| if (shouldTransform) { |
| expect(llmConfig.temperature).toBeUndefined(); |
| expect(llmConfig.maxTokens).toBeUndefined(); |
| expect(llmConfig.modelKwargs?.max_completion_tokens).toBe(1500); |
| } else { |
| expect(llmConfig.temperature).toBe(0.5); |
| expect(llmConfig.maxTokens).toBe(1500); |
| expect(llmConfig.modelKwargs).toBeUndefined(); |
| } |
| } |
| }); |
|
|
| it('should use default model (gpt-4.1-mini) without temperature removal when no llmConfig provided', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-4.1-mini', |
| temperature: 0.4, |
| }), |
| }), |
| }), |
| ); |
| }); |
|
|
| it('should use max_output_tokens when useResponsesApi is true', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model: 'gpt-5', |
| maxTokens: 1000, |
| useResponsesApi: true, |
| }, |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-5', |
| modelKwargs: { |
| max_output_tokens: 1000, |
| }, |
| }), |
| }), |
| }), |
| ); |
| }); |
|
|
| it('should use max_completion_tokens when useResponsesApi is false or undefined', async () => { |
| await processMemory({ |
| res: mockRes as Response, |
| userId: 'test-user', |
| setMemory: mockSetMemory, |
| deleteMemory: mockDeleteMemory, |
| messages: [], |
| memory: 'Test memory', |
| messageId: 'msg-123', |
| conversationId: 'conv-123', |
| instructions: 'Test instructions', |
| llmConfig: { |
| provider: Providers.OPENAI, |
| model: 'gpt-5', |
| maxTokens: 1000, |
| useResponsesApi: false, |
| }, |
| }); |
|
|
| const { Run } = jest.requireMock('@librechat/agents'); |
| expect(Run.create).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| graphConfig: expect.objectContaining({ |
| llmConfig: expect.objectContaining({ |
| model: 'gpt-5', |
| modelKwargs: { |
| max_completion_tokens: 1000, |
| }, |
| }), |
| }), |
| }), |
| ); |
| }); |
| }); |
|
|