first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/unbound-method, @typescript-eslint/no-explicit-any */
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { MemoryManager } from '../MemoryManager.node';
|
||||
|
||||
/**
|
||||
* These tests verify that the Memory Manager resolves sub-node expressions
|
||||
* (e.g. Session ID) per input item, not just once for item 0.
|
||||
*
|
||||
* See: https://github.com/n8n-io/n8n/issues/23890
|
||||
*/
|
||||
|
||||
interface MockMemory {
|
||||
memory: BaseChatMemory;
|
||||
getMessages: jest.Mock;
|
||||
addMessage: jest.Mock;
|
||||
clear: jest.Mock;
|
||||
}
|
||||
|
||||
function createMockMemory(messages: BaseMessage[] = []): MockMemory {
|
||||
const getMessages = jest.fn().mockResolvedValue([...messages]);
|
||||
const addMessage = jest.fn().mockResolvedValue(undefined);
|
||||
const clear = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const memory = { chatHistory: { getMessages, addMessage, clear } } as unknown as BaseChatMemory;
|
||||
|
||||
return { memory, getMessages, addMessage, clear };
|
||||
}
|
||||
|
||||
function createMockContext() {
|
||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||
(mockHelpers.constructExecutionMetaData as any).mockImplementation(
|
||||
(items: INodeExecutionData[], meta: { itemData: any }) =>
|
||||
items.map((item) => ({ ...item, pairedItem: meta.itemData })),
|
||||
);
|
||||
mockHelpers.returnJsonArray.mockImplementation(
|
||||
(data) =>
|
||||
(Array.isArray(data) ? data : [data]).map((d) => ({
|
||||
json: d,
|
||||
})) as INodeExecutionData[],
|
||||
);
|
||||
|
||||
const ctx = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||
ctx.getNode.mockReturnValue({
|
||||
name: 'Chat Memory Manager',
|
||||
typeVersion: 1.1,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe('MemoryManager.execute - multi-item session context', () => {
|
||||
let node: MemoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new MemoryManager();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('insert mode', () => {
|
||||
it('should request memory with correct itemIndex for each input item', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([
|
||||
{ json: { name: 'First item', code: 1 } },
|
||||
{ json: { name: 'Second item', code: 2 } },
|
||||
{ json: { name: 'Third item', code: 3 } },
|
||||
]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'mode') return 'insert';
|
||||
if (param === 'insertMode') return 'insert';
|
||||
if (param === 'messages.messageValues')
|
||||
return [{ type: 'system', message: 'test', hideFromUI: false }];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const memories = [createMockMemory(), createMockMemory(), createMockMemory()];
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(memories[0].memory)
|
||||
.mockResolvedValueOnce(memories[1].memory)
|
||||
.mockResolvedValueOnce(memories[2].memory);
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(ctx.getInputConnectionData).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
NodeConnectionTypes.AiMemory,
|
||||
0,
|
||||
);
|
||||
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
NodeConnectionTypes.AiMemory,
|
||||
1,
|
||||
);
|
||||
expect(ctx.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
NodeConnectionTypes.AiMemory,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert messages into separate sessions for each item', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([
|
||||
{ json: { name: 'First item', code: 1 } },
|
||||
{ json: { name: 'Second item', code: 2 } },
|
||||
]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, i, defaultValue) => {
|
||||
if (param === 'mode') return 'insert';
|
||||
if (param === 'insertMode') return 'insert';
|
||||
if (param === 'messages.messageValues')
|
||||
return [{ type: 'system', message: `Message for item ${i}`, hideFromUI: false }];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mock1 = createMockMemory();
|
||||
const mock2 = createMockMemory();
|
||||
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(mock1.memory)
|
||||
.mockResolvedValueOnce(mock2.memory);
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mock1.addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mock2.addMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cross-contaminate sessions in override mode with multiple items', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([
|
||||
{ json: { name: 'First item', code: 1 } },
|
||||
{ json: { name: 'Second item', code: 2 } },
|
||||
]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, i, defaultValue) => {
|
||||
if (param === 'mode') return 'insert';
|
||||
if (param === 'insertMode') return 'override';
|
||||
if (param === 'messages.messageValues') {
|
||||
const names = ['First item', 'Second item'];
|
||||
return [{ type: 'system', message: names[i], hideFromUI: false }];
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mock1 = createMockMemory();
|
||||
const mock2 = createMockMemory();
|
||||
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(mock1.memory)
|
||||
.mockResolvedValueOnce(mock2.memory);
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
// Each memory should be cleared and written to exactly once
|
||||
expect(mock1.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mock2.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mock1.addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mock2.addMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load mode', () => {
|
||||
it('should load messages from the correct session for each item', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([
|
||||
{ json: { sessionId: 'session-A' } },
|
||||
{ json: { sessionId: 'session-B' } },
|
||||
]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'mode') return 'load';
|
||||
if (param === 'simplifyOutput') return true;
|
||||
if (param === 'options') return { groupMessages: false };
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mockA = createMockMemory([new SystemMessage('Message from session A')]);
|
||||
const mockB = createMockMemory([new SystemMessage('Message from session B')]);
|
||||
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(mockA.memory)
|
||||
.mockResolvedValueOnce(mockB.memory);
|
||||
|
||||
const result = await node.execute.call(ctx);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
|
||||
const item0Message = result[0][0].json;
|
||||
const item1Message = result[0][1].json;
|
||||
|
||||
expect(item0Message).toHaveProperty('system', 'Message from session A');
|
||||
expect(item1Message).toHaveProperty('system', 'Message from session B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete mode', () => {
|
||||
it('should delete messages from the correct session for each item', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([{ json: { code: 1 } }, { json: { code: 2 } }]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'mode') return 'delete';
|
||||
if (param === 'deleteMode') return 'all';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mock1 = createMockMemory([new SystemMessage('Session 1 msg')]);
|
||||
const mock2 = createMockMemory([new SystemMessage('Session 2 msg')]);
|
||||
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(mock1.memory)
|
||||
.mockResolvedValueOnce(mock2.memory);
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mock1.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mock2.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should delete last N messages from the correct session for each item', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
ctx.getInputData.mockReturnValue([{ json: { code: 1 } }, { json: { code: 2 } }]);
|
||||
|
||||
ctx.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'mode') return 'delete';
|
||||
if (param === 'deleteMode') return 'lastN';
|
||||
if (param === 'lastMessagesCount') return 1;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const mock1 = createMockMemory([
|
||||
new SystemMessage('Session 1 keep'),
|
||||
new SystemMessage('Session 1 delete'),
|
||||
]);
|
||||
const mock2 = createMockMemory([
|
||||
new SystemMessage('Session 2 keep'),
|
||||
new SystemMessage('Session 2 delete'),
|
||||
]);
|
||||
|
||||
ctx.getInputConnectionData
|
||||
.mockResolvedValueOnce(mock1.memory)
|
||||
.mockResolvedValueOnce(mock2.memory);
|
||||
|
||||
await node.execute.call(ctx);
|
||||
|
||||
expect(mock1.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mock2.clear).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After deleting last 1 message, each memory should have its "keep" message re-added
|
||||
expect(mock1.addMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Session 1 keep' }),
|
||||
);
|
||||
expect(mock2.addMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Session 2 keep' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
|
||||
import { simplifyMessages } from '../MemoryManager.node';
|
||||
|
||||
describe('simplifyMessages', () => {
|
||||
it('should handle single message', () => {
|
||||
const messages = [new HumanMessage('Hello')];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([{ human: 'Hello' }]);
|
||||
});
|
||||
|
||||
it('should group different message types together', () => {
|
||||
const messages = [
|
||||
new HumanMessage('Hello, how are you?'),
|
||||
new AIMessage("I'm doing well, thank you for asking! How about you?"),
|
||||
];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
human: 'Hello, how are you?',
|
||||
ai: "I'm doing well, thank you for asking! How about you?",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should separate consecutive messages of same type into different groups', () => {
|
||||
const messages = [
|
||||
new HumanMessage('First human message'),
|
||||
new HumanMessage('Second human message'),
|
||||
new AIMessage('AI response'),
|
||||
];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([
|
||||
{ human: 'First human message' },
|
||||
{ human: 'Second human message', ai: 'AI response' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle three consecutive messages of same type', () => {
|
||||
const messages = [new HumanMessage('1'), new HumanMessage('2'), new HumanMessage('3')];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([{ human: '1' }, { human: '2' }, { human: '3' }]);
|
||||
});
|
||||
|
||||
it('should handle mixed message types with grouping', () => {
|
||||
const messages = [
|
||||
new SystemMessage('System message'),
|
||||
new HumanMessage('Hello'),
|
||||
new AIMessage('Hi there'),
|
||||
new HumanMessage('Another human message'),
|
||||
new AIMessage('Another AI message'),
|
||||
];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
system: 'System message',
|
||||
human: 'Hello',
|
||||
ai: 'Hi there',
|
||||
},
|
||||
{
|
||||
human: 'Another human message',
|
||||
ai: 'Another AI message',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle system messages correctly', () => {
|
||||
const messages = [
|
||||
new SystemMessage('System instruction'),
|
||||
new HumanMessage('User question'),
|
||||
new AIMessage('AI response'),
|
||||
];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
system: 'System instruction',
|
||||
human: 'User question',
|
||||
ai: 'AI response',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle alternating same types correctly', () => {
|
||||
const messages = [
|
||||
new HumanMessage('Human 1'),
|
||||
new AIMessage('AI 1'),
|
||||
new HumanMessage('Human 2'),
|
||||
new AIMessage('AI 2'),
|
||||
];
|
||||
const result = simplifyMessages(messages);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
human: 'Human 1',
|
||||
ai: 'AI 1',
|
||||
},
|
||||
{
|
||||
human: 'Human 2',
|
||||
ai: 'AI 2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user