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:
@@ -0,0 +1,417 @@
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { MessageContent, BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, SystemMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
type MessageRole = 'ai' | 'system' | 'user';
|
||||
interface MessageRecord {
|
||||
type: MessageRole;
|
||||
message: string;
|
||||
hideFromUI: boolean;
|
||||
}
|
||||
|
||||
export function simplifyMessages(messages: BaseMessage[]): Array<Record<string, MessageContent>> {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const result: Array<Record<string, MessageContent>> = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < messages.length) {
|
||||
const currentGroup: Record<string, MessageContent> = {};
|
||||
|
||||
do {
|
||||
const message = messages[index];
|
||||
const messageType = message.getType();
|
||||
|
||||
if (messageType in currentGroup) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentGroup[messageType] = message.content;
|
||||
index++;
|
||||
} while (index < messages.length);
|
||||
|
||||
result.push(currentGroup);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const prepareOutputSetup = (ctx: IExecuteFunctions, version: number, memory: BaseChatMemory) => {
|
||||
if (version === 1) {
|
||||
//legacy behavior of insert and delete for version 1
|
||||
return async (i: number) => {
|
||||
const messages = await memory.chatHistory.getMessages();
|
||||
|
||||
const serializedMessages = messages?.map((message) => message.toJSON()) ?? [];
|
||||
|
||||
const executionData = ctx.helpers.constructExecutionMetaData(
|
||||
ctx.helpers.returnJsonArray(serializedMessages as unknown as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
return executionData;
|
||||
};
|
||||
}
|
||||
return async (i: number) => {
|
||||
return [
|
||||
{
|
||||
json: { success: true },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
export class MemoryManager implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Chat Memory Manager',
|
||||
name: 'memoryManager',
|
||||
icon: 'fa:database',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Manage chat messages memory and use it in the workflow',
|
||||
defaults: {
|
||||
name: 'Chat Memory Manager',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.memorymanager/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [
|
||||
{
|
||||
displayName: '',
|
||||
type: NodeConnectionTypes.Main,
|
||||
},
|
||||
{
|
||||
displayName: 'Memory',
|
||||
type: NodeConnectionTypes.AiMemory,
|
||||
required: true,
|
||||
maxConnections: 1,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{
|
||||
displayName: '',
|
||||
type: NodeConnectionTypes.Main,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
default: 'load',
|
||||
options: [
|
||||
{
|
||||
name: 'Get Many Messages',
|
||||
description: 'Retrieve chat messages from connected memory',
|
||||
value: 'load',
|
||||
},
|
||||
{
|
||||
name: 'Insert Messages',
|
||||
description: 'Insert chat messages into connected memory',
|
||||
value: 'insert',
|
||||
},
|
||||
{
|
||||
name: 'Delete Messages',
|
||||
description: 'Delete chat messages from connected memory',
|
||||
value: 'delete',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Insert Mode',
|
||||
name: 'insertMode',
|
||||
type: 'options',
|
||||
description: 'Choose how new messages are inserted into the memory',
|
||||
noDataExpression: true,
|
||||
default: 'insert',
|
||||
options: [
|
||||
{
|
||||
name: 'Insert Messages',
|
||||
value: 'insert',
|
||||
description: 'Add messages alongside existing ones',
|
||||
},
|
||||
{
|
||||
name: 'Override All Messages',
|
||||
value: 'override',
|
||||
description: 'Replace the current memory with new messages',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['insert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Delete Mode',
|
||||
name: 'deleteMode',
|
||||
type: 'options',
|
||||
description: 'How messages are deleted from memory',
|
||||
noDataExpression: true,
|
||||
default: 'lastN',
|
||||
options: [
|
||||
{
|
||||
name: 'Last N',
|
||||
value: 'lastN',
|
||||
description: 'Delete the last N messages',
|
||||
},
|
||||
{
|
||||
name: 'All Messages',
|
||||
value: 'all',
|
||||
description: 'Clear all messages from memory',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['delete'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Chat Messages',
|
||||
name: 'messages',
|
||||
description: 'Chat messages to insert into memory',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add message',
|
||||
options: [
|
||||
{
|
||||
name: 'messageValues',
|
||||
displayName: 'Message',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Type Name or ID',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'AI',
|
||||
value: 'ai',
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: 'system',
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
],
|
||||
default: 'system',
|
||||
},
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Hide Message in Chat',
|
||||
name: 'hideFromUI',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
default: false,
|
||||
description: 'Whether to hide the message from the chat UI',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['insert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Messages Count',
|
||||
name: 'lastMessagesCount',
|
||||
type: 'number',
|
||||
description: 'The amount of last messages to delete',
|
||||
default: 2,
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['delete'],
|
||||
deleteMode: ['lastN'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplifyOutput',
|
||||
type: 'boolean',
|
||||
description: 'Whether to simplify the output to only include the sender and the text',
|
||||
default: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['load'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Group Messages',
|
||||
name: 'groupMessages',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to group messages into a single item or return each message as a separate item',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['load'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
const items = this.getInputData();
|
||||
const mode = this.getNodeParameter('mode', 0, 'load') as 'load' | 'insert' | 'delete';
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const memory = (await this.getInputConnectionData(
|
||||
NodeConnectionTypes.AiMemory,
|
||||
i,
|
||||
)) as BaseChatMemory;
|
||||
|
||||
const prepareOutput = prepareOutputSetup(this, nodeVersion, memory);
|
||||
const messages = await memory.chatHistory.getMessages();
|
||||
|
||||
if (mode === 'delete') {
|
||||
const deleteMode = this.getNodeParameter('deleteMode', i) as 'lastN' | 'all';
|
||||
|
||||
if (deleteMode === 'lastN') {
|
||||
const lastMessagesCount = this.getNodeParameter('lastMessagesCount', i) as number;
|
||||
if (messages.length >= lastMessagesCount) {
|
||||
const newMessages = messages.slice(0, messages.length - lastMessagesCount);
|
||||
|
||||
await memory.chatHistory.clear();
|
||||
for (const message of newMessages) {
|
||||
await memory.chatHistory.addMessage(message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await memory.chatHistory.clear();
|
||||
}
|
||||
|
||||
returnData.push(...(await prepareOutput(i)));
|
||||
}
|
||||
|
||||
if (mode === 'insert') {
|
||||
const insertMode = this.getNodeParameter('insertMode', i) as 'insert' | 'override';
|
||||
const messagesToInsert = this.getNodeParameter(
|
||||
'messages.messageValues',
|
||||
i,
|
||||
[],
|
||||
) as MessageRecord[];
|
||||
|
||||
const templateMapper = {
|
||||
ai: AIMessage,
|
||||
system: SystemMessage,
|
||||
user: HumanMessage,
|
||||
};
|
||||
|
||||
if (insertMode === 'override') {
|
||||
await memory.chatHistory.clear();
|
||||
}
|
||||
|
||||
for (const message of messagesToInsert) {
|
||||
const MessageClass = new templateMapper[message.type](message.message);
|
||||
|
||||
if (message.hideFromUI) {
|
||||
MessageClass.additional_kwargs.hideFromUI = true;
|
||||
}
|
||||
|
||||
await memory.chatHistory.addMessage(MessageClass);
|
||||
}
|
||||
|
||||
returnData.push(...(await prepareOutput(i)));
|
||||
}
|
||||
|
||||
if (mode === 'load') {
|
||||
const simplifyOutput = this.getNodeParameter('simplifyOutput', i, false) as boolean;
|
||||
const options = this.getNodeParameter('options', i);
|
||||
|
||||
//Load mode, legacy behavior for version 1, buggy - outputs only for single input item
|
||||
if (simplifyOutput && messages.length && nodeVersion === 1) {
|
||||
const groupMessages = options.groupMessages as boolean;
|
||||
const output = simplifyMessages(messages);
|
||||
|
||||
return [
|
||||
this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(
|
||||
groupMessages ? [{ messages: output, messagesCount: output.length }] : output,
|
||||
),
|
||||
{ itemData: { item: i } },
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
let groupMessages = true;
|
||||
//disable grouping if explicitly set to false
|
||||
if (options.groupMessages === false) {
|
||||
groupMessages = false;
|
||||
}
|
||||
//disable grouping if not set and node version is 1 (legacy behavior)
|
||||
if (options.groupMessages === undefined && nodeVersion === 1) {
|
||||
groupMessages = false;
|
||||
}
|
||||
|
||||
let output: IDataObject[] =
|
||||
(simplifyOutput
|
||||
? simplifyMessages(messages)
|
||||
: (messages?.map((message) => message.toJSON()) as unknown as IDataObject[])) ?? [];
|
||||
|
||||
if (groupMessages) {
|
||||
output = [{ messages: output, messagesCount: output.length }];
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(output),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
+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