test: Add tests to ai-utilities (#25443)

Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
yehorkardash
2026-02-13 09:10:59 +01:00
committed by GitHub
parent 66dcdb9c8c
commit 4b4783ff58
27 changed files with 3562 additions and 23 deletions
@@ -0,0 +1,614 @@
import { tool } from 'langchain';
import { Readable } from 'node:stream';
import z from 'zod';
export const weatherTool = tool(
({ city }) => {
return `It's always sunny in ${city}!`;
},
{
name: 'get_weather',
description: 'Get weather for a given city.',
schema: z.object({
city: z.string(),
}),
},
);
export const mockToolCallResponse = {
id: 'resp_02a127c1e73b5fe4016989e989cb188195a27d4911084e4223',
object: 'response',
created_at: 1770645897,
status: 'completed',
background: false,
billing: { payer: 'developer' },
completed_at: 1770645898,
error: null,
frequency_penalty: 0,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'fc_02a127c1e73b5fe4016989e98a70b881959fb6cf1d58b5db8b',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
},
],
parallel_tool_calls: true,
presence_penalty: 0,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: 1,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [
{
type: 'function',
description: 'Get weather for a given city.',
name: 'get_weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
strict: true,
},
],
top_logprobs: 0,
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 46,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 15,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 61,
},
user: null,
metadata: {},
};
export const mockFinalResponse = {
id: 'resp_00a8729c01103919016989e98b13888190bca486b9676ce0cd',
object: 'response',
created_at: 1770645899,
status: 'completed',
background: false,
billing: { payer: 'developer' },
completed_at: 1770645899,
error: null,
frequency_penalty: 0,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'msg_00a8729c01103919016989e98bb58c81909a9eb194728f1db0',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
],
parallel_tool_calls: true,
presence_penalty: 0,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: 1,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [
{
type: 'function',
description: 'Get weather for a given city.',
name: 'get_weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
strict: true,
},
],
top_logprobs: 0,
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 76,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 8,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 84,
},
user: null,
metadata: {},
};
export const mockStreamToolCallEvents = [
{
type: 'event',
data: {
type: 'response.created',
response: {
id: 'resp_stream_001',
object: 'response',
created_at: 1770647361,
status: 'in_progress',
model: 'gpt-4o-2024-08-06',
},
sequence_number: 0,
},
},
{
type: 'event',
data: {
type: 'response.in_progress',
response: {
id: 'resp_stream_001',
status: 'in_progress',
},
sequence_number: 1,
},
},
{
type: 'event',
data: {
type: 'response.output_item.added',
item: {
id: 'fc_stream_001',
type: 'function_call',
status: 'in_progress',
arguments: '',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
output_index: 0,
sequence_number: 2,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '{"',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 3,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: 'city',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 4,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '":"',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 5,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: 'Tokyo',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 6,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '"}',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 7,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.done',
arguments: '{"city":"Tokyo"}',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 8,
},
},
{
type: 'event',
data: {
type: 'response.output_item.done',
item: {
id: 'fc_stream_001',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
output_index: 0,
sequence_number: 9,
},
},
{
type: 'event',
data: {
type: 'response.completed',
response: {
id: 'resp_stream_001',
object: 'response',
created_at: 1770647361,
status: 'completed',
completed_at: 1770647362,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'fc_stream_001',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
],
usage: {
input_tokens: 46,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens: 15,
output_tokens_details: {
reasoning_tokens: 0,
},
total_tokens: 61,
},
},
sequence_number: 10,
},
},
{
type: 'done',
data: null,
},
];
export const mockStreamFinalResponseEvents = [
{
type: 'event',
data: {
type: 'response.created',
response: {
id: 'resp_stream_002',
object: 'response',
created_at: 1770647362,
status: 'in_progress',
model: 'gpt-4o-2024-08-06',
},
sequence_number: 0,
},
},
{
type: 'event',
data: {
type: 'response.in_progress',
response: {
id: 'resp_stream_002',
status: 'in_progress',
},
sequence_number: 1,
},
},
{
type: 'event',
data: {
type: 'response.output_item.added',
item: {
id: 'msg_stream_002',
type: 'message',
status: 'in_progress',
content: [],
role: 'assistant',
},
output_index: 0,
sequence_number: 2,
},
},
{
type: 'event',
data: {
type: 'response.content_part.added',
content_index: 0,
item_id: 'msg_stream_002',
output_index: 0,
part: {
type: 'output_text',
annotations: [],
logprobs: [],
text: '',
},
sequence_number: 3,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: "It's",
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 4,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' always',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 5,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' sunny',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 6,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' in',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 7,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' Tokyo',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 8,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: '!',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 9,
},
},
{
type: 'event',
data: {
type: 'response.output_text.done',
content_index: 0,
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 10,
text: "It's always sunny in Tokyo!",
},
},
{
type: 'event',
data: {
type: 'response.content_part.done',
content_index: 0,
item_id: 'msg_stream_002',
output_index: 0,
part: {
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
sequence_number: 11,
},
},
{
type: 'event',
data: {
type: 'response.output_item.done',
item: {
id: 'msg_stream_002',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
output_index: 0,
sequence_number: 12,
},
},
{
type: 'event',
data: {
type: 'response.completed',
response: {
id: 'resp_stream_002',
object: 'response',
created_at: 1770647362,
status: 'completed',
completed_at: 1770647363,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'msg_stream_002',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
],
usage: {
input_tokens: 76,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens: 8,
output_tokens_details: {
reasoning_tokens: 0,
},
total_tokens: 84,
},
},
sequence_number: 13,
},
},
{
type: 'done',
data: null,
},
];
export function createSSEStream(events: Array<{ type: string; data: unknown }>) {
const stream = new Readable({
read() {},
});
let eventIndex = 0;
function sendData() {
setTimeout(() => {
if (eventIndex < events.length) {
const event = events[eventIndex];
if (event.type === 'done') {
stream.push('data: [DONE]\n\n');
stream.push(null);
} else {
stream.push(`data: ${JSON.stringify(event.data)}\n\n`);
}
eventIndex++;
sendData();
}
}, 50);
}
sendData();
return stream;
}
export function createMockHttpRequests() {
return {
httpRequest: async (
method: string,
url: string,
body?: object,
headers?: Record<string, string>,
) => {
const response = await fetch(url, {
method,
body: JSON.stringify(body),
headers: {
...headers,
Authorization: 'Bearer test-api-key',
},
});
const json = await response.json();
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: json,
};
},
openStream: async (
method: string,
url: string,
body?: object,
headers?: Record<string, string>,
) => {
const response = await fetch(url, {
method,
body: JSON.stringify(body),
headers: {
...headers,
Authorization: 'Bearer test-api-key',
},
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: response.body as ReadableStream<Uint8Array<ArrayBufferLike>>,
};
},
};
}
@@ -0,0 +1,279 @@
import { createAgent, HumanMessage } from 'langchain';
import nock from 'nock';
import {
createMockHttpRequests,
createSSEStream,
mockFinalResponse,
mockStreamFinalResponseEvents,
mockStreamToolCallEvents,
mockToolCallResponse,
weatherTool,
} from './openai.fixtures';
import { OpenAIChatModel } from '../examples/models/openai';
import { LangchainAdapter } from '../src/adapters/langchain-chat-model';
describe('OpenAI Integration with Langchain Agent', () => {
const baseURL = 'https://api.openai.com/v1';
beforeEach(() => {
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});
it('should execute agent with tool calling through langchain adapter', async () => {
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: 'What is the weather in tokyo?',
tools: [
{
type: 'function',
name: 'get_weather',
description: 'Get weather for a given city.',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
},
},
required: ['city'],
additionalProperties: false,
},
},
],
parallel_tool_calls: true,
store: false,
stream: false,
});
return true;
})
.reply(200, mockToolCallResponse);
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: expect.arrayContaining([
{ role: 'user', content: 'What is the weather in tokyo?' },
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: '' }],
},
{
type: 'function_call',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
arguments: '{"city":"Tokyo"}',
},
{
type: 'function_call_output',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
output: "It's always sunny in Tokyo!",
},
]),
parallel_tool_calls: true,
store: false,
stream: false,
});
return true;
})
.reply(200, mockFinalResponse);
const openaiChatModel = new OpenAIChatModel('gpt-4o', createMockHttpRequests(), { baseURL });
const chatModel = new LangchainAdapter(openaiChatModel);
const agent = createAgent({
model: chatModel,
tools: [weatherTool],
});
const result = await agent.invoke({
messages: [new HumanMessage('What is the weather in tokyo?')],
});
expect(result).toBeDefined();
expect(result.messages).toHaveLength(4);
expect(result.messages[0]).toMatchObject({
content: 'What is the weather in tokyo?',
});
expect(result.messages[1]).toMatchObject({
id: 'resp_02a127c1e73b5fe4016989e989cb188195a27d4911084e4223',
tool_calls: [
{
type: 'tool_call',
id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
args: {
city: 'Tokyo',
},
},
],
});
expect(result.messages[2]).toMatchObject({
content: "It's always sunny in Tokyo!",
name: 'get_weather',
tool_call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
});
expect(result.messages[3]).toMatchObject({
id: 'resp_00a8729c01103919016989e98b13888190bca486b9676ce0cd',
content: [
{
type: 'text',
text: "It's always sunny in Tokyo!",
},
],
});
expect(nock.isDone()).toBe(true);
});
it('should execute agent with streaming through langchain adapter', async () => {
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: 'What is the weather in tokyo?',
stream: true,
});
return true;
})
.reply(() => {
const stream = createSSEStream(mockStreamToolCallEvents);
return [
200,
stream,
{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
];
});
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
stream: true,
});
return true;
})
.reply(() => {
const stream = createSSEStream(mockStreamFinalResponseEvents);
return [
200,
stream,
{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
];
});
const openaiChatModel = new OpenAIChatModel('gpt-4o', createMockHttpRequests(), { baseURL });
const chatModel = new LangchainAdapter(openaiChatModel);
const agent = createAgent({
model: chatModel,
tools: [weatherTool],
});
const chunks: unknown[] = [];
const stream = await agent.stream(
{ messages: [{ role: 'user', content: 'What is the weather in tokyo?' }] },
{ streamMode: 'messages' },
);
for await (const chunk of stream) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(10);
const getChunkData = (chunk: unknown) => {
const chunkArray = chunk as unknown[];
return {
message: chunkArray[0] as Record<string, unknown>,
metadata: chunkArray[1] as Record<string, unknown>,
};
};
const { message: message1, metadata: metadata1 } = getChunkData(chunks[0]);
const toolCalls1 = message1.tool_calls as Array<Record<string, unknown>>;
expect(toolCalls1).toHaveLength(1);
expect(toolCalls1[0]).toMatchObject({
name: 'get_weather',
args: {
city: 'Tokyo',
},
id: 'call_StreamTest123',
type: 'tool_call',
});
expect(metadata1.langgraph_step).toBeDefined();
const { message: message2 } = getChunkData(chunks[1]);
expect(message2.usage_metadata).toEqual({
input_tokens: 46,
output_tokens: 15,
total_tokens: 61,
});
const responseMetadata2 = message2.response_metadata as Record<string, unknown>;
expect(responseMetadata2.finish_reason).toBe('stop');
const { message: message3 } = getChunkData(chunks[2]);
expect(message3.content).toBe("It's always sunny in Tokyo!");
expect(message3.tool_call_id).toBe('call_StreamTest123');
expect(message3.name).toBe('get_weather');
const { message: message4 } = getChunkData(chunks[3]);
const content4 = message4.content as Array<{ type: string; text: string }>;
expect(content4[0].text).toBe("It's");
const { message: message5 } = getChunkData(chunks[4]);
const content5 = message5.content as Array<{ type: string; text: string }>;
expect(content5[0].text).toBe(' always');
const { message: message6 } = getChunkData(chunks[5]);
const content6 = message6.content as Array<{ type: string; text: string }>;
expect(content6[0].text).toBe(' sunny');
const { message: message7 } = getChunkData(chunks[6]);
const content7 = message7.content as Array<{ type: string; text: string }>;
expect(content7[0].text).toBe(' in');
const { message: message8 } = getChunkData(chunks[7]);
const content8 = message8.content as Array<{ type: string; text: string }>;
expect(content8[0].text).toBe(' Tokyo');
const { message: message9 } = getChunkData(chunks[8]);
const content9 = message9.content as Array<{ type: string; text: string }>;
expect(content9[0].text).toBe('!');
const { message: message10 } = getChunkData(chunks[9]);
expect(message10.usage_metadata).toEqual({
input_tokens: 76,
output_tokens: 8,
total_tokens: 84,
});
const responseMetadata10 = message10.response_metadata as Record<string, unknown>;
expect(responseMetadata10.finish_reason).toBe('stop');
for (const chunk of chunks) {
const { metadata } = getChunkData(chunk);
expect(metadata).toBeDefined();
expect(metadata.langgraph_step).toBeDefined();
}
expect(nock.isDone()).toBe(true);
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
collectCoverageFrom: ['src/**/*.ts'],
collectCoverageFrom: ['src/**/*.ts', 'integration-tests/**/*.ts'],
setupFilesAfterEnv: ['jest-expect-message'],
coveragePathIgnorePatterns: ['examples'],
};
@@ -0,0 +1,310 @@
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { HumanMessage } from '@langchain/core/messages';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import type { GenerateResult, StreamChunk } from 'src/types/output';
import { LangchainAdapter } from '../../adapters/langchain-chat-model';
jest.mock('src/converters/tool', () => ({
fromLcTool: jest.fn().mockImplementation((t: { name?: string }) => ({
type: 'function' as const,
name: t?.name ?? 'tool',
description: '',
inputSchema: { type: 'object' as const },
})),
}));
jest.mock('src/utils/n8n-llm-tracing', () => ({
N8nLlmTracing: jest.fn().mockImplementation(function (this: unknown) {
return this;
}),
}));
jest.mock('src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler', () => ({
makeN8nLlmFailedAttemptHandler: jest.fn().mockReturnValue(jest.fn()),
}));
const { fromLcTool } = jest.requireMock('src/converters/tool');
const { N8nLlmTracing } = jest.requireMock('src/utils/n8n-llm-tracing');
const { makeN8nLlmFailedAttemptHandler } = jest.requireMock(
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler',
);
function createMockChatModel(
overrides: {
generate?: jest.Mock;
stream?: jest.Mock;
withTools?: jest.Mock;
} = {},
) {
const generate = jest.fn();
const stream = jest.fn();
const withTools = jest.fn().mockImplementation(function (
this: ReturnType<typeof createMockChatModel>,
) {
return this;
});
return {
provider: 'test-provider',
modelId: 'test-model',
generate: overrides.generate ?? generate,
stream: overrides.stream ?? stream,
withTools: overrides.withTools ?? withTools,
};
}
describe('LangchainAdapter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('passes callbacks and onFailedAttempt when ctx is provided', () => {
const ctx = {
getNode: jest.fn(),
addOutputData: jest.fn(),
} as unknown as ISupplyDataFunctions;
const chatModel = createMockChatModel();
new LangchainAdapter(chatModel, ctx);
expect(N8nLlmTracing).toHaveBeenCalledWith(ctx, expect.any(Object));
expect(makeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(ctx);
});
it('does not pass callbacks or onFailedAttempt when ctx is omitted', () => {
const chatModel = createMockChatModel();
new LangchainAdapter(chatModel);
expect(N8nLlmTracing).not.toHaveBeenCalled();
expect(makeN8nLlmFailedAttemptHandler).not.toHaveBeenCalled();
});
});
describe('_llmType', () => {
it('returns "n8n-chat-model"', () => {
const adapter = new LangchainAdapter(createMockChatModel());
expect(adapter._llmType()).toBe('n8n-chat-model');
});
});
describe('_generate', () => {
it('transforms messages and calls chatModel.generate', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
},
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainAdapter(chatModel);
const messages = [new HumanMessage('hello')];
const options = { temperature: 0.5 };
const result = await adapter._generate(messages, options);
expect(chatModel.generate).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
options,
);
expect(result.generations).toHaveLength(1);
expect(result.generations[0].text).toBe('Hi there');
expect(result.generations[0].message.content).toEqual([{ type: 'text', text: 'Hi there' }]);
});
it('builds usage_metadata from result.usage', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
inputTokenDetails: { cacheRead: 5 },
outputTokenDetails: { reasoning: 15 },
},
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainAdapter(chatModel);
const result = await adapter._generate([new HumanMessage('x')], {});
const msg = result.generations[0].message as any;
expect(msg.usage_metadata).toEqual({
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
input_token_details: { cache_read: 5 },
output_token_details: { reasoning: 15 },
});
expect(result.llmOutput?.tokenUsage).toEqual(msg.usage_metadata);
});
it('maps result.toolCalls to message tool_calls and sets provider metadata', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'get_weather',
input: JSON.stringify({ location: 'Berlin' }),
},
{ type: 'text', text: 'Hello, world!' },
],
},
id: 'gen-1',
providerMetadata: { finish_reason: 'tool_calls' },
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainAdapter(chatModel);
const result = await adapter._generate([new HumanMessage('hi')], {});
const msg = result.generations[0].message as any;
expect(msg.tool_calls).toEqual([
{ type: 'tool_call', id: 'tc-1', name: 'get_weather', args: { location: 'Berlin' } },
]);
expect(msg.response_metadata).toEqual(
expect.objectContaining({
model: 'test-model',
provider: 'test-provider',
finish_reason: 'tool_calls',
}),
);
expect(result.llmOutput?.id).toBe('gen-1');
});
});
describe('_streamResponseChunks', () => {
it('yields ChatGenerationChunk for text-delta chunks and calls runManager.handleLLMNewToken', async () => {
async function* stream() {
const response: StreamChunk = {
type: 'text-delta',
delta: 'Hello ',
};
const response2: StreamChunk = {
type: 'text-delta',
delta: 'world',
};
yield response;
yield response2;
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainAdapter(chatModel);
const handleLLMNewToken = jest.fn();
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {}, {
handleLLMNewToken,
} as unknown as CallbackManagerForLLMRun)) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(2);
expect(chunks[0].text).toBe('Hello ');
expect(chunks[1].text).toBe('world');
expect(handleLLMNewToken).toHaveBeenCalledWith(
'Hello ',
expect.any(Object),
undefined,
undefined,
undefined,
expect.any(Object),
);
expect(handleLLMNewToken).toHaveBeenCalledWith(
'world',
expect.any(Object),
undefined,
undefined,
undefined,
expect.any(Object),
);
});
it('yields ChatGenerationChunk for tool-call-delta chunks', async () => {
async function* stream() {
const response: StreamChunk = {
type: 'tool-call-delta',
id: 'tc-1',
name: 'search',
argumentsDelta: '{"q":"x"}',
};
yield response;
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainAdapter(chatModel);
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {})) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(1);
expect(chunks[0].message.tool_call_chunks).toEqual([
{ type: 'tool_call_chunk', id: 'tc-1', name: 'search', args: '{"q":"x"}', index: 0 },
]);
});
it('yields ChatGenerationChunk for finish chunks with usage_metadata', async () => {
async function* stream() {
yield {
type: 'finish' as const,
finishReason: 'stop' as const,
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
};
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainAdapter(chatModel);
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {})) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(1);
expect(chunks[0].message.usage_metadata).toEqual({
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
});
expect(chunks[0].generationInfo?.finish_reason).toBe('stop');
});
});
describe('bindTools', () => {
it('converts tools via fromLcTool, calls chatModel.withTools, and returns new LangchainAdapter', () => {
const chatModel = createMockChatModel();
const adapter = new LangchainAdapter(chatModel, undefined);
const lcTools = [{ name: 'my_tool', schema: {}, invoke: jest.fn() }];
const bound = adapter.bindTools(lcTools);
expect(fromLcTool).toHaveBeenCalledWith(lcTools[0], 0, lcTools);
expect(chatModel.withTools).toHaveBeenCalledWith([
{ type: 'function', name: 'my_tool', description: '', inputSchema: { type: 'object' } },
]);
expect(bound).toBeInstanceOf(LangchainAdapter);
expect(bound).not.toBe(adapter);
});
});
});
@@ -408,7 +408,9 @@ describe('message round-trip: LC -> n8n -> LC', () => {
it('should round-trip an AIMessage with tool_calls', () => {
const original = new AIMessage({
content: 'Let me search for that.',
tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'weather' } }],
tool_calls: [
{ type: 'tool_call', id: 'call-1', name: 'search', args: { query: 'weather' } },
],
});
const result = roundTrip(original);
@@ -0,0 +1,156 @@
import type { JSONSchema7 } from 'json-schema';
import { z } from 'zod';
import { fromLcTool, getParametersJsonSchema } from '../../converters/tool';
describe('fromLcTool', () => {
it('converts StructuredTool (schema + invoke) to N8n function tool', () => {
const tool = {
name: 'search',
description: 'Search the web',
schema: { type: 'object', properties: { q: { type: 'string' } } },
invoke: jest.fn(),
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'search',
description: 'Search the web',
inputSchema: { type: 'object', properties: { q: { type: 'string' } } },
});
});
it('converts DynamicStructuredTool (schema + func) to N8n function tool', () => {
const tool = {
name: 'calculator',
description: 'Do math',
schema: { type: 'object', properties: { a: { type: 'number' } } },
func: jest.fn(),
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'calculator',
description: 'Do math',
inputSchema: { type: 'object', properties: { a: { type: 'number' } } },
});
});
it('converts tool with name and schema (no invoke/func) to N8n function tool', () => {
const tool = {
name: 'lookup',
description: 'Look up data',
schema: { type: 'object' },
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'lookup',
description: 'Look up data',
inputSchema: { type: 'object' },
});
});
it('converts FunctionDefinition (function + type === "function") to N8n function tool', () => {
const parameters: JSONSchema7 = {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
};
const tool = {
type: 'function' as const,
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters,
},
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'get_weather',
description: 'Get weather for a location',
inputSchema: parameters,
});
});
it('throws when tool format is unrecognized', () => {
const tool = { unknown: 'shape' };
expect(() => fromLcTool(tool)).toThrow(
'Unable to convert tool to N8nTool: {"unknown":"shape"}',
);
});
it('throws when tool is empty object', () => {
expect(() => fromLcTool({})).toThrow('Unable to convert tool to N8nTool');
});
});
describe('getParametersJsonSchema', () => {
it('returns schema as-is when inputSchema is plain JSONSchema7', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: { id: { type: 'string' } },
};
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: schema,
};
const result = getParametersJsonSchema(tool);
expect(result).toBe(schema);
expect(result).toEqual({
type: 'object',
properties: { id: { type: 'string' } },
});
});
it('returns schema.toJSONSchema() when ZodSchema has toJSONSchema method', () => {
const jsonSchema: JSONSchema7 = { type: 'object', properties: {} };
const zodSchema = z.object({ x: z.string() });
(zodSchema as { toJSONSchema?: () => JSONSchema7 }).toJSONSchema = jest
.fn()
.mockReturnValue(jsonSchema);
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: zodSchema,
};
const result = getParametersJsonSchema(tool);
expect(result).toBe(jsonSchema);
expect((zodSchema as unknown as { toJSONSchema: jest.Mock }).toJSONSchema).toHaveBeenCalled();
});
it('returns zodToJsonSchema(schema) when ZodSchema has no toJSONSchema', () => {
const zodSchema = z.object({ name: z.string() });
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: zodSchema,
};
const result = getParametersJsonSchema(tool);
expect(result).toEqual(
expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
name: expect.objectContaining({ type: 'string' }),
}),
}),
);
});
});
@@ -1,5 +0,0 @@
describe('empty', () => {
it('should be empty', () => {
expect(true).toBe(true);
});
});
@@ -0,0 +1,248 @@
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { supplyModel } from 'src/suppliers/supplyModel';
const mockLangchainAdapterInstance = { __brand: 'LangchainAdapter' };
jest.mock('@langchain/openai', () => ({
ChatOpenAI: jest.fn().mockImplementation(function (this: any) {
// Return a new object each time so metadata can be set independently
return { __brand: 'ChatOpenAI', metadata: {} };
}),
}));
jest.mock('src/utils/http-proxy-agent', () => ({
getProxyAgent: jest.fn().mockReturnValue({ __agent: true }),
}));
jest.mock('src/utils/n8n-llm-tracing', () => ({
N8nLlmTracing: jest.fn().mockImplementation(function (this: unknown) {
return this;
}),
}));
jest.mock('src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler', () => ({
makeN8nLlmFailedAttemptHandler: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock('src/adapters/langchain-chat-model', () => ({
LangchainAdapter: jest.fn().mockImplementation(() => mockLangchainAdapterInstance),
}));
const { ChatOpenAI } = jest.requireMock('@langchain/openai');
const { LangchainAdapter } = jest.requireMock('src/adapters/langchain-chat-model');
const { getProxyAgent } = jest.requireMock('src/utils/http-proxy-agent');
const { makeN8nLlmFailedAttemptHandler } = jest.requireMock(
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler',
);
const { N8nLlmTracing } = jest.requireMock('src/utils/n8n-llm-tracing');
describe('supplyModel', () => {
const mockCtx = {
getNode: jest.fn(),
addOutputData: jest.fn(),
addInputData: jest.fn(),
getNextRunIndex: jest.fn(),
} as unknown as ISupplyDataFunctions;
beforeEach(() => {
jest.clearAllMocks();
});
describe('OpenAI model path', () => {
it('returns response from ChatOpenAI when model has type "openai"', () => {
const openAiModel = {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'test-key',
};
const result = supplyModel(mockCtx, openAiModel);
expect(result.response).toEqual(
expect.objectContaining({
__brand: 'ChatOpenAI',
metadata: {},
}),
);
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'test-key',
configuration: expect.objectContaining({
baseURL: 'https://api.openai.com',
}),
onFailedAttempt: expect.any(Function),
callbacks: [expect.any(Object)],
}),
);
expect(makeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(mockCtx, undefined);
expect(N8nLlmTracing).toHaveBeenCalledWith(mockCtx);
expect(LangchainAdapter).not.toHaveBeenCalled();
});
it('passes ctx and OpenAI options to ChatOpenAI when model has defaultHeaders and timeout', () => {
const openAiModel = {
type: 'openai' as const,
baseUrl: 'https://api.example.com',
model: 'gpt-4',
apiKey: 'key',
defaultHeaders: { 'X-Custom': 'value' },
timeout: 60_000,
};
supplyModel(mockCtx, openAiModel);
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'key',
configuration: expect.objectContaining({
baseURL: 'https://api.example.com',
defaultHeaders: { 'X-Custom': 'value' },
}),
}),
);
});
it('includes providerTools in metadata when model has providerTools', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [{ type: 'provider', name: 'web_search', args: { size: 'medium' } }],
});
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'key',
}),
);
// Verify that the returned model has the correct metadata with providerTools
// The providerTools should be mapped to metadata.tools format
expect((result.response as any).metadata).toEqual({
tools: [
{
type: 'web_search',
size: 'medium',
},
],
});
});
it('maps multiple providerTools correctly in metadata', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [
{ type: 'provider', name: 'web_search', args: { engine: 'google', limit: 10 } },
{ type: 'provider', name: 'code_interpreter', args: { timeout: 30 } },
],
});
// Verify that all providerTools are correctly mapped to metadata.tools
expect((result.response as any).metadata.tools).toHaveLength(2);
expect((result.response as any).metadata.tools[0]).toEqual({
type: 'web_search',
engine: 'google',
limit: 10,
});
expect((result.response as any).metadata.tools[1]).toEqual({
type: 'code_interpreter',
timeout: 30,
});
});
it('does not set metadata.tools when providerTools is empty', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [],
});
// Empty providerTools should not set metadata.tools
expect((result.response as any).metadata).toEqual({});
});
it('sets timeout in OpenAI class and in fetchOptions', () => {
supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
timeout: 12345,
});
expect(getProxyAgent).toHaveBeenCalledWith('https://api.openai.com', {
headersTimeout: 12345,
bodyTimeout: 12345,
});
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 12345,
}),
);
});
});
describe('ChatModel (LangchainAdapter) path', () => {
it('returns response from LangchainAdapter when model does not have type "openai"', () => {
const chatModel = {
provider: 'anthropic',
modelId: 'claude-3',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, chatModel);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainAdapter).toHaveBeenCalledTimes(1);
expect(LangchainAdapter).toHaveBeenCalledWith(chatModel, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
it('uses LangchainAdapter when model has type other than "openai"', () => {
const modelWithOtherType = {
type: 'custom',
provider: 'custom',
modelId: 'custom-model',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, modelWithOtherType);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainAdapter).toHaveBeenCalledWith(modelWithOtherType, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
it('uses LangchainAdapter when model has no type property', () => {
const modelWithoutType = {
provider: 'google',
modelId: 'gemini-pro',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, modelWithoutType);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainAdapter).toHaveBeenCalledWith(modelWithoutType, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,81 @@
import type { INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
validateEmbedQueryInput,
validateEmbedDocumentsInput,
} from 'src/utils/embeddings-input-validation';
describe('validateEmbedQueryInput', () => {
const mockNode: INode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
it('should return valid non-empty string', () => {
const result = validateEmbedQueryInput('valid query', mockNode);
expect(result).toBe('valid query');
});
it('should throw NodeOperationError for invalid input with proper description', () => {
expect(() => validateEmbedQueryInput('', mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedQueryInput(undefined, mockNode)).toThrow(NodeOperationError);
try {
validateEmbedQueryInput('', mockNode);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
const nodeError = error as NodeOperationError;
expect(nodeError.description).toContain('text provided for embedding is empty or undefined');
}
});
});
describe('validateEmbedDocumentsInput', () => {
const mockNode: INode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
it('should return valid array of strings', () => {
const docs = ['doc1', 'doc2', 'doc3'];
const result = validateEmbedDocumentsInput(docs, mockNode);
expect(result).toEqual(docs);
});
it('should throw NodeOperationError for non-array input with proper description', () => {
expect(() => validateEmbedDocumentsInput('not an array', mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedDocumentsInput(undefined, mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedDocumentsInput({}, mockNode)).toThrow(NodeOperationError);
try {
validateEmbedDocumentsInput('not array', mockNode);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
const nodeError = error as NodeOperationError;
expect(nodeError.description).toContain('Expected an array of strings');
}
});
it('should throw NodeOperationError for invalid document at correct index', () => {
const docs = ['valid', undefined, 'valid2'];
expect(() => validateEmbedDocumentsInput(docs, mockNode)).toThrow(
'Invalid document at index 1',
);
const docs2 = ['valid1', 'valid2', null, 'valid3'];
expect(() => validateEmbedDocumentsInput(docs2, mockNode)).toThrow(
'Invalid document at index 2',
);
});
});
@@ -1,4 +1,4 @@
import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler';
import { n8nDefaultFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler';
class MockHttpError extends Error {
response: { status: number };
@@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { ApplicationError, NodeApiError } from 'n8n-workflow';
import { makeN8nLlmFailedAttemptHandler } from './n8nLlmFailedAttemptHandler';
import { makeN8nLlmFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
describe('makeN8nLlmFailedAttemptHandler', () => {
const ctx = mock<ISupplyDataFunctions>({
@@ -1,4 +1,4 @@
import { hasLongSequentialRepeat } from './helpers';
import { hasLongSequentialRepeat } from 'src/utils/helpers';
describe('hasLongSequentialRepeat', () => {
it('should return false for text shorter than threshold', () => {
@@ -1,6 +1,6 @@
import { Agent, ProxyAgent } from 'undici';
import { getProxyAgent, proxyFetch } from './http-proxy-agent';
import { getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
// Mock the dependencies
jest.mock('undici', () => ({
@@ -0,0 +1,78 @@
import type { AiEvent, IDataObject, IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { logAiEvent } from 'src/utils/log-ai-event';
describe('logAiEvent', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions | ISupplyDataFunctions>;
let mockLogger: { debug: jest.Mock };
beforeEach(() => {
mockLogger = {
debug: jest.fn(),
};
mockExecuteFunctions = {
logAiEvent: jest.fn(),
logger: mockLogger,
} as unknown as jest.Mocked<IExecuteFunctions | ISupplyDataFunctions>;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('successful logging', () => {
it('should log AI event without data', () => {
const event: AiEvent = 'ai-llm-generated-output';
logAiEvent(mockExecuteFunctions, event);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith(event, undefined);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1);
});
it('should log AI event with data object', () => {
const event: AiEvent = 'ai-llm-generated-output';
const data: IDataObject = { response: 'test response', tokens: 100 };
logAiEvent(mockExecuteFunctions, event, data);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith(event, JSON.stringify(data));
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1);
});
it('should log different AI event types', () => {
const events: AiEvent[] = ['ai-llm-generated-output', 'ai-llm-errored', 'ai-tool-called'];
const data: IDataObject = { test: 'data' };
events.forEach((event) => {
logAiEvent(mockExecuteFunctions, event, data);
});
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(3);
});
});
it('should catch error and log debug message when logAiEvent throws', () => {
const event: AiEvent = 'ai-llm-generated-output';
const error = new Error('Logging failed');
mockExecuteFunctions.logAiEvent.mockImplementation(() => {
throw error;
});
// Should not throw
expect(() => logAiEvent(mockExecuteFunctions, event)).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(`Error logging AI event: ${event}`);
});
it('should handle JSON.stringify errors gracefully', () => {
const event: AiEvent = 'ai-llm-generated-output';
const circularData: IDataObject = {};
circularData.self = circularData; // Create circular reference
// Should not throw
expect(() => logAiEvent(mockExecuteFunctions, event, circularData)).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(`Error logging AI event: ${event}`);
});
});
@@ -0,0 +1,567 @@
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import type { IBinaryData, IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import { Readable } from 'stream';
import { N8nBinaryLoader } from 'src/utils/n8n-binary-loader';
// Mock the helpers module
jest.mock('src/utils/helpers', () => ({
getMetadataFiltersValues: jest.fn(),
}));
// Mock LangChain loaders
jest.mock('@langchain/classic/document_loaders/fs/json', () => ({
JSONLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'json content', metadata: {} }]),
})),
}));
jest.mock('@langchain/classic/document_loaders/fs/text', () => ({
TextLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'text content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/csv', () => ({
CSVLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'csv content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/docx', () => ({
DocxLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'docx content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/epub', () => ({
EPubLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'epub content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/pdf', () => ({
PDFLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'pdf content', metadata: {} }]),
})),
}));
const { getMetadataFiltersValues } = jest.requireMock('src/utils/helpers');
describe('N8nBinaryLoader', () => {
let mockContext: jest.MockedObjectDeep<IExecuteFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockContext = {
getNode: jest.fn().mockReturnValue(mockNode),
getNodeParameter: jest.fn(),
getInputData: jest.fn().mockReturnValue([]),
helpers: {
assertBinaryData: jest.fn(),
binaryToBuffer: jest.fn(),
getBinaryStream: jest.fn(),
},
} as unknown as jest.MockedObjectDeep<IExecuteFunctions>;
getMetadataFiltersValues.mockReturnValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default parameters', () => {
const loader = new N8nBinaryLoader(mockContext);
expect(loader).toBeInstanceOf(N8nBinaryLoader);
});
it('should create instance with all parameters', () => {
const mockSplitter = {} as TextSplitter;
const loader = new N8nBinaryLoader(mockContext, 'prefix.', 'binaryKey', mockSplitter);
expect(loader).toBeInstanceOf(N8nBinaryLoader);
});
});
describe('processAll', () => {
it('should return empty array for undefined items', async () => {
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processAll(undefined);
expect(result).toEqual([]);
});
it('should return empty array for empty items', async () => {
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processAll([]);
expect(result).toEqual([]);
});
it('should process multiple items', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const items: INodeExecutionData[] = [
{ json: {}, binary: { file: mockBinaryData } },
{ json: {}, binary: { file: mockBinaryData } },
];
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
});
});
describe('processItem - singleFile mode', () => {
it('should process text file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { data: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'data');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'data');
});
it('should process PDF file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
if (param === 'splitPages') return false;
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/pdf',
data: Buffer.from('fake pdf content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { document: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'document');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('splitPages', 0, false);
});
it('should process CSV file with options', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'csvLoader';
if (param === 'column') return 'text';
if (param === 'separator') return ';';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/csv',
data: Buffer.from('col1;col2\nval1;val2').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { csv: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'csv');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('column', 0, null);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('separator', 0, ',');
});
it('should process JSON file with pointers', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'jsonLoader';
if (param === 'pointers') return '/data, /items';
return undefined;
});
const jsonData = JSON.stringify({ data: 'test', items: ['item1', 'item2'] });
const mockBinaryData: IBinaryData = {
mimeType: 'application/json',
data: Buffer.from(jsonData).toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { json: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'json');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('pointers', 0, '');
});
});
describe('processItem - allInputData mode', () => {
it('should process all binary data from input', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'allInputData';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
mockContext.getInputData.mockReturnValue([
{
json: {},
binary: {
file1: mockBinaryData,
file2: mockBinaryData,
},
},
]);
const item: INodeExecutionData = {
json: {},
binary: {
file1: mockBinaryData,
file2: mockBinaryData,
},
};
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getInputData).toHaveBeenCalled();
});
it('should handle empty binary data in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'allInputData';
return undefined;
});
mockContext.getInputData.mockReturnValue([{ json: {} }]);
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toEqual([]);
});
});
describe('validateMimeType', () => {
it('should throw error when loader does not match mime type', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain', // Wrong mime type for pdfLoader
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow(
"Mime type doesn't match selected loader",
);
});
it('should throw error for unsupported mime type', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'video/mp4', // Unsupported mime type
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow('Unsupported mime type');
});
it('should accept valid mime type for auto loader', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
});
describe('binary data with ID', () => {
it('should handle binary data with ID', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
id: 'binary-123',
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
const mockStream = Buffer.from('test content');
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
mockContext.helpers.getBinaryStream.mockResolvedValue(Readable.from(mockStream));
mockContext.helpers.binaryToBuffer.mockResolvedValue(mockStream);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.helpers.getBinaryStream).toHaveBeenCalledWith('binary-123');
expect(mockContext.helpers.binaryToBuffer).toHaveBeenCalled();
});
});
describe('metadata handling', () => {
it('should add custom metadata to documents', async () => {
const customMetadata = { source: 'test', type: 'document' };
getMetadataFiltersValues.mockReturnValue(customMetadata);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result.length).toBeGreaterThan(0);
expect(result[0].metadata).toMatchObject(customMetadata);
});
});
describe('text splitter integration', () => {
it('should use text splitter when provided', async () => {
const mockDocuments: Document[] = [
{ pageContent: 'split 1', metadata: {} },
{ pageContent: 'split 2', metadata: {} },
];
const mockSplitter = {
splitDocuments: jest.fn().mockResolvedValue(mockDocuments),
} as unknown as TextSplitter;
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file', mockSplitter);
await loader.processItem(item, 0);
expect(mockSplitter.splitDocuments).toHaveBeenCalled();
});
});
describe('options prefix', () => {
it('should use options prefix for loader parameters', async () => {
const prefix = 'options.';
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
if (param === `${prefix}splitPages`) return true;
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/pdf',
data: Buffer.from('fake pdf').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { pdf: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, prefix, 'pdf');
await loader.processItem(item, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(`${prefix}splitPages`, 0, false);
});
});
it('should process text file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
it('should process JSON file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'jsonLoader';
if (param === 'pointers') return '';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/json',
data: Buffer.from(JSON.stringify({ test: 'content' })).toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
});
@@ -0,0 +1,320 @@
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { N8nJsonLoader } from 'src/utils/n8n-json-loader';
// Mock the helpers module
jest.mock('src/utils/helpers', () => ({
getMetadataFiltersValues: jest.fn(),
}));
const { getMetadataFiltersValues } = jest.requireMock('src/utils/helpers');
describe('N8nJsonLoader', () => {
let mockContext: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockContext = {
getNode: jest.fn().mockReturnValue(mockNode),
getNodeParameter: jest.fn(),
} as unknown as jest.Mocked<IExecuteFunctions>;
getMetadataFiltersValues.mockReturnValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default parameters', () => {
const loader = new N8nJsonLoader(mockContext);
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
it('should create instance with options prefix', () => {
const loader = new N8nJsonLoader(mockContext, 'prefix.');
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
it('should create instance with text splitter', () => {
const mockSplitter = {
splitDocuments: jest.fn(),
} as unknown as TextSplitter;
const loader = new N8nJsonLoader(mockContext, '', mockSplitter);
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
});
describe('processAll', () => {
it('should return empty array for undefined items', async () => {
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(undefined);
expect(result).toEqual([]);
});
it('should return empty array for empty items', async () => {
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll([]);
expect(result).toEqual([]);
});
it('should process single item', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const items: INodeExecutionData[] = [
{
json: { message: 'test data' },
},
];
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
it('should process multiple items', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const items: INodeExecutionData[] = [
{ json: { message: 'item 1' } },
{ json: { message: 'item 2' } },
{ json: { message: 'item 3' } },
];
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
});
describe('processItem - allInputData mode', () => {
it('should process item in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data', nested: { value: 123 } },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty('pageContent');
expect(result[0]).toHaveProperty('metadata');
});
it('should process item with JSON pointers in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '/test, /nested/value';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data', nested: { value: 123 } },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('pointers', 0, '');
});
});
it('should process string data in expressionData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'expressionData';
if (param === 'jsonData') return 'plain text data';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('jsonData', 0);
});
it('should process object data in expressionData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'expressionData';
if (param === 'jsonData') return { test: 'object data' };
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
it('should add metadata to documents', async () => {
const metadata = { source: 'test', category: 'document' };
getMetadataFiltersValues.mockReturnValue(metadata);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result.length).toBeGreaterThan(0);
expect(result[0].metadata).toMatchObject(metadata);
});
it('should use text splitter when provided', async () => {
const mockDocuments: Document[] = [
{ pageContent: 'split content 1', metadata: {} },
{ pageContent: 'split content 2', metadata: {} },
];
const mockSplitter = {
splitDocuments: jest.fn().mockResolvedValue(mockDocuments),
} as unknown as TextSplitter;
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext, '', mockSplitter);
const result = await loader.processItem(item, 0);
expect(mockSplitter.splitDocuments).toHaveBeenCalled();
expect(result).toEqual(mockDocuments);
});
it('should use options prefix for pointers parameter', async () => {
const prefix = 'customPrefix.';
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === `${prefix}pointers`) return '/custom';
return '';
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext, prefix);
await loader.processItem(item, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(`${prefix}pointers`, 0, '');
});
it('should return empty array for null item', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(null as unknown as INodeExecutionData, 0);
expect(result).toEqual([]);
});
it('should throw NodeOperationError when document loader is not initialized', async () => {
// Mock a scenario where documentLoader stays null
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'unknownMode'; // Invalid mode
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext);
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow('Document loader is not initialized');
});
it('should handle complex JSON structures with nesting and arrays', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {
level1: {
level2: {
level3: {
value: 'deep value',
},
},
},
items: ['item1', 'item2', 'item3'],
},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,600 @@
import type { Serialized } from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { N8nLlmTracing } from 'src/utils/n8n-llm-tracing';
// Mock the dependencies
jest.mock('src/utils/log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
jest.mock('src/utils/tokenizer/token-estimator', () => ({
estimateTokensFromStringList: jest.fn().mockResolvedValue(100),
}));
const { logAiEvent } = jest.requireMock('src/utils/log-ai-event');
const { estimateTokensFromStringList } = jest.requireMock('src/utils/tokenizer/token-estimator');
describe('N8nLlmTracing', () => {
let mockExecutionFunctions: jest.Mocked<ISupplyDataFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockExecutionFunctions = {
getNode: jest.fn().mockReturnValue(mockNode),
addOutputData: jest.fn(),
addInputData: jest.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: jest.fn().mockReturnValue(0),
} as unknown as jest.Mocked<ISupplyDataFunctions>;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default options', () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
expect(tracer).toBeInstanceOf(N8nLlmTracing);
expect(tracer.name).toBe('N8nLlmTracing');
expect(tracer.awaitHandlers).toBe(true);
expect(tracer.connectionType).toBe(NodeConnectionTypes.AiLanguageModel);
});
it('should create instance with custom tokensUsageParser', () => {
const customParser = jest.fn().mockReturnValue({
completionTokens: 50,
promptTokens: 30,
totalTokens: 80,
});
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
tokensUsageParser: customParser,
});
expect(tracer).toBeInstanceOf(N8nLlmTracing);
});
it('should create instance with custom errorDescriptionMapper', () => {
const customMapper = jest.fn().mockReturnValue('Custom error description');
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
errorDescriptionMapper: customMapper,
});
expect(tracer).toBeInstanceOf(N8nLlmTracing);
});
});
describe('handleLLMStart', () => {
it('should handle LLM start event with prompts', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'llms', 'openai'],
kwargs: { modelName: 'gpt-4', temperature: 0.7 },
};
const prompts = ['What is the capital of France?'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(mockExecutionFunctions.addInputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
messages: prompts,
estimatedTokens: expect.any(Number),
options: llm.kwargs,
}),
}),
]),
]),
undefined,
);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(prompts, 'gpt-4o');
});
it('should store run details for later use', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'not_implemented',
id: ['langchain', 'llms', 'test'],
};
const prompts = ['Test prompt'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(tracer.runsMap[runId]).toBeDefined();
expect(tracer.runsMap[runId].messages).toEqual(prompts);
expect(tracer.runsMap[runId].index).toBe(0);
});
it('should handle multiple prompts', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'llms', 'openai'],
kwargs: {},
};
const prompts = ['Prompt 1', 'Prompt 2', 'Prompt 3'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(tracer.runsMap[runId].messages).toEqual(prompts);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(prompts, 'gpt-4o');
});
it('should use parent run index when set', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
tracer.setParentRunIndex(5);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['test'],
kwargs: {},
};
mockExecutionFunctions.getNextRunIndex.mockReturnValue(2);
await tracer.handleLLMStart(llm, ['test'], 'run-123');
expect(mockExecutionFunctions.addInputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
expect.any(Array),
7, // 5 (parent) + 2 (next)
);
});
});
describe('handleLLMEnd', () => {
it('should handle LLM end event with token usage', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
// Setup run
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test prompt'],
options: {},
};
tracer.promptTokensEstimate = 50;
const output: LLMResult = {
generations: [[{ text: 'Response text', generationInfo: {} }]],
llmOutput: {
tokenUsage: {
completionTokens: 30,
promptTokens: 50,
totalTokens: 80,
},
},
};
await tracer.handleLLMEnd(output, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
response: expect.objectContaining({
generations: expect.any(Array),
}),
tokenUsage: expect.objectContaining({
completionTokens: 30,
promptTokens: 50,
totalTokens: 80,
}),
}),
}),
]),
]),
undefined,
undefined,
);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-generated-output',
expect.any(Object),
);
});
it('should use token estimates when actual tokens not available', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test prompt'],
options: {},
};
tracer.promptTokensEstimate = 50;
estimateTokensFromStringList.mockResolvedValue(25);
const output: LLMResult = {
generations: [[{ text: 'Response text' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const outputData = callArgs?.[2]?.[0]?.[0]?.json;
expect(outputData.tokenUsageEstimate).toBeDefined();
expect(outputData.tokenUsageEstimate.completionTokens).toBe(25);
expect(outputData.tokenUsageEstimate.promptTokens).toBe(50);
expect(outputData.tokenUsageEstimate.totalTokens).toBe(75);
});
it('should handle string messages', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: 'Simple string message',
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-generated-output',
expect.objectContaining({
messages: 'Simple string message',
}),
);
});
it('should handle BaseMessage objects with toJSON', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const mockMessage: Partial<BaseMessage> = {
toJSON: jest.fn().mockReturnValue({ content: 'test', role: 'user' }),
};
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: [mockMessage as BaseMessage],
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
expect(mockMessage.toJSON).toHaveBeenCalled();
});
it('should handle missing run details gracefully', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
// Set up minimal run details with index but no messages
tracer.runsMap['non-existent-run'] = {
index: 0,
messages: [],
options: {},
};
// Run without full setup
await tracer.handleLLMEnd(output, 'non-existent-run');
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalled();
});
it('should strip unnecessary fields from generation info', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const output: LLMResult = {
generations: [
[
{
text: 'Response',
generationInfo: { model: 'gpt-4' },
extraField: 'should be removed',
} as any,
],
],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const generations = callArgs[2][0][0].json.response.generations[0][0];
expect(generations).toHaveProperty('text');
expect(generations).toHaveProperty('generationInfo');
expect(generations).not.toHaveProperty('extraField');
});
});
describe('handleLLMError', () => {
it('should handle NodeError', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new NodeOperationError(mockNode, 'Test error', {
description: 'Test description',
});
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
error,
);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-errored',
expect.objectContaining({
error: expect.any(Object),
runId,
}),
);
});
it('should wrap non-NodeError errors', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new Error('Generic error');
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
expect.any(NodeOperationError),
);
});
it('should filter out non-x- headers from error', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = {
headers: {
'x-request-id': '123',
authorization: 'Bearer token',
'content-type': 'application/json',
'x-custom-header': 'value',
},
};
await tracer.handleLLMError(error, runId);
expect(error.headers).toHaveProperty('x-request-id');
expect(error.headers).toHaveProperty('x-custom-header');
expect(error.headers).not.toHaveProperty('authorization');
expect(error.headers).not.toHaveProperty('content-type');
});
it('should use custom error description mapper', async () => {
const customMapper = jest.fn().mockReturnValue('Custom description');
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
errorDescriptionMapper: customMapper,
});
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new NodeOperationError(mockNode, 'Test error');
await tracer.handleLLMError(error, runId);
expect(customMapper).toHaveBeenCalledWith(error);
expect(error.description).toBe('Custom description');
});
it('should handle error with empty object', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = {};
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalled();
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-errored',
expect.objectContaining({
error: expect.any(String), // Should call toString()
}),
);
});
});
describe('token estimation', () => {
it('should estimate tokens from generation', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const generations: LLMResult['generations'] = [
[{ text: 'Response 1' }, { text: 'Response 2' }],
];
estimateTokensFromStringList.mockResolvedValue(42);
const result = await tracer.estimateTokensFromGeneration(generations);
expect(result).toBe(42);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(
['Response 1', 'Response 2'],
'gpt-4o',
);
});
it('should estimate tokens from string list', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const list = ['String 1', 'String 2', 'String 3'];
estimateTokensFromStringList.mockResolvedValue(75);
const result = await tracer.estimateTokensFromStringList(list);
expect(result).toBe(75);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(list, 'gpt-4o');
});
});
describe('setParentRunIndex', () => {
it('should set parent run index', () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
tracer.setParentRunIndex(10);
// The private field can't be accessed directly, but we can verify behavior
// in handleLLMStart
expect(tracer).toBeDefined();
});
});
describe('custom token usage parser', () => {
it('should use custom token usage parser', async () => {
const customParser = jest.fn().mockReturnValue({
completionTokens: 100,
promptTokens: 50,
totalTokens: 150,
});
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
tokensUsageParser: customParser,
});
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: { customTokenData: 'test' },
};
await tracer.handleLLMEnd(output, runId);
expect(customParser).toHaveBeenCalledWith(output);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const outputData = callArgs[2][0][0].json;
expect(outputData.tokenUsage).toEqual({
completionTokens: 100,
promptTokens: 50,
totalTokens: 150,
});
});
});
describe('runsMap management', () => {
it('should track multiple runs', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['test'],
kwargs: {},
};
await tracer.handleLLMStart(llm, ['Prompt 1'], 'run-1');
await tracer.handleLLMStart(llm, ['Prompt 2'], 'run-2');
await tracer.handleLLMStart(llm, ['Prompt 3'], 'run-3');
expect(Object.keys(tracer.runsMap)).toHaveLength(3);
expect(tracer.runsMap['run-1']).toBeDefined();
expect(tracer.runsMap['run-2']).toBeDefined();
expect(tracer.runsMap['run-3']).toBeDefined();
});
});
});
@@ -0,0 +1,281 @@
import type { ServerSentEventMessage } from 'src/utils/sse';
import { parseSSEStream } from 'src/utils/sse';
describe('parseSSEStream', () => {
// Helper to create a ReadableStream from string chunks
function createStreamFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let index = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(encoder.encode(chunks[index]));
index++;
} else {
controller.close();
}
},
});
}
// Helper to collect all events from stream
async function collectEvents(
stream: ReadableStream<Uint8Array>,
): Promise<ServerSentEventMessage[]> {
const events: ServerSentEventMessage[] = [];
for await (const event of parseSSEStream(stream)) {
events.push(event);
}
return events;
}
it('should parse simple data-only event', async () => {
const stream = createStreamFromChunks(['data: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
it('should parse multiple events', async () => {
const stream = createStreamFromChunks(['data: first\n\ndata: second\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ data: 'first' });
expect(events[1]).toEqual({ data: 'second' });
});
it('should parse complete event with all fields', async () => {
const stream = createStreamFromChunks([
'event: update\nid: 42\ndata: test data\nretry: 5000\n\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
event: 'update',
id: 42,
data: 'test data',
retry: 5000,
});
});
it('should parse event with string id', async () => {
const stream = createStreamFromChunks(['id: abc-123\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ id: 'abc-123', data: 'hello' });
});
describe('multi-line data', () => {
it('should join multiple data fields with newlines', async () => {
const stream = createStreamFromChunks(['data: line 1\ndata: line 2\ndata: line 3\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'line 1\nline 2\nline 3' });
});
it('should handle empty data fields', async () => {
const stream = createStreamFromChunks(['data: first\ndata:\ndata: third\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'first\n\nthird' });
});
});
it('should handle mixed line endings (LF, CRLF, CR)', async () => {
const stream = createStreamFromChunks(['data: line1\r\ndata: line2\ndata: line3\r\r']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'line1\nline2\nline3' });
});
it('should handle comments and trim leading space', async () => {
const stream = createStreamFromChunks([': comment with spaces\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ comment: 'comment with spaces', data: 'hello' });
});
describe('field value parsing', () => {
it('should remove single leading space after colon', async () => {
const stream = createStreamFromChunks(['data: value with space\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
// First space is removed, subsequent spaces are preserved
expect(events[0]).toEqual({ data: ' value with space' });
});
it('should handle field with no value', async () => {
const stream = createStreamFromChunks(['data\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
// Field with no value results in undefined data per SSE spec
expect(events[0]).toEqual({ data: undefined });
});
it('should handle empty id field (should not set id)', async () => {
const stream = createStreamFromChunks(['id:\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].id).toBeUndefined();
});
it('should ignore invalid retry values', async () => {
const stream = createStreamFromChunks(['retry: invalid\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].retry).toBeUndefined();
});
it('should ignore negative retry values', async () => {
const stream = createStreamFromChunks(['retry: -1000\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].retry).toBeUndefined();
});
it('should ignore unknown fields', async () => {
const stream = createStreamFromChunks(['unknown: field\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
});
it('should handle data split across chunks', async () => {
const stream = createStreamFromChunks(['data: hel', 'lo\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
it('should handle multiple events split across chunks', async () => {
const stream = createStreamFromChunks(['data: fir', 'st\n\nda', 'ta: sec', 'ond\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ data: 'first' });
expect(events[1]).toEqual({ data: 'second' });
});
it('should handle UTF-8 sequences split across chunks', async () => {
// Split a multi-byte UTF-8 character across chunks
const encoder = new TextEncoder();
const fullText = 'data: 你好\n\n';
const bytes = encoder.encode(fullText);
// Split in the middle of a multi-byte character
const chunk1 = bytes.slice(0, 8);
const chunk2 = bytes.slice(8);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(chunk1);
controller.enqueue(chunk2);
controller.close();
},
});
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: '你好' });
});
it('should handle empty stream', async () => {
const stream = createStreamFromChunks([]);
const events = await collectEvents(stream);
expect(events).toHaveLength(0);
});
it('should handle incomplete event at end of stream', async () => {
const stream = createStreamFromChunks(['data: incomplete']);
const events = await collectEvents(stream);
// Incomplete events are flushed at end
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'incomplete' });
});
it('should not yield events with no content and handle empty lines', async () => {
const stream = createStreamFromChunks(['\n\n\n\ndata: hello\n\n\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
describe('real-world scenarios', () => {
it('should parse typical SSE chat stream', async () => {
const stream = createStreamFromChunks([
'event: message\n',
'id: 1\n',
'data: {"text": "Hello"}\n',
'\n',
'event: message\n',
'id: 2\n',
'data: {"text": " world"}\n',
'\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ event: 'message', id: 1, data: '{"text": "Hello"}' });
expect(events[1]).toEqual({ event: 'message', id: 2, data: '{"text": " world"}' });
});
it('should parse OpenAI-style streaming', async () => {
const stream = createStreamFromChunks([
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
'data: [DONE]\n\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(3);
expect(events[0]).toEqual({ data: '{"choices":[{"delta":{"content":"Hello"}}]}' });
expect(events[1]).toEqual({ data: '{"choices":[{"delta":{"content":" world"}}]}' });
expect(events[2]).toEqual({ data: '[DONE]' });
});
it('should parse events with metadata and heartbeats', async () => {
const stream = createStreamFromChunks([
': heartbeat\n',
'\n',
'event: status\n',
'data: connected\n',
'\n',
': heartbeat\n',
'\n',
'event: data\n',
'data: actual data\n',
'\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
// First event includes comment from preceding line
expect(events[0]).toEqual({ comment: 'heartbeat', event: 'status', data: 'connected' });
expect(events[1]).toEqual({ comment: 'heartbeat', event: 'data', data: 'actual data' });
});
});
});
@@ -6,7 +6,7 @@
import type { TiktokenEncoding } from 'js-tiktoken/lite';
import { Tiktoken } from 'js-tiktoken/lite';
import { getEncoding, encodingForModel } from '../tiktoken';
import { getEncoding, encodingForModel } from 'src/utils/tokenizer/tiktoken';
jest.mock('js-tiktoken/lite', () => ({
Tiktoken: jest.fn(),
@@ -4,7 +4,7 @@ import {
estimateTokensByCharCount,
estimateTextSplitsByTokens,
estimateTokensFromStringList,
} from '../token-estimator';
} from 'src/utils/tokenizer/token-estimator';
describe('token-estimator', () => {
describe('estimateTokensByCharCount', () => {
@@ -57,7 +57,6 @@ export class LangchainAdapter<
messages: BaseMessage[],
options: this['ParsedCallOptions'],
): Promise<ChatResult> {
// Convert LangChain messages to generic messages
const transformedMessages = messages.map(fromLcMessage);
const result = await this.chatModel.generate(transformedMessages, options);
// Build content blocks for the message
@@ -84,7 +84,7 @@ function isInvalidToolCallBlock(
function isToolResultBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Tools.ServerToolCallResult {
return block.type === 'tool-result';
return block.type === 'server_tool_call_result';
}
function isCitationBlock(block: unknown): block is LangchainMessages.ContentBlock.Citation {
return (
@@ -329,11 +329,14 @@ export function toLcMessage(message: Message): LangchainMessages.BaseMessage {
name: message.name,
});
case 'assistant': {
const toolCalls = message.content.filter(isN8nToolCallBlock).map((c) => ({
id: c.toolCallId,
name: c.toolName,
args: jsonParse<Record<string, unknown>>(c.input, { fallbackValue: {} }),
}));
const toolCalls: LangchainMessages.ToolCall[] = message.content
.filter(isN8nToolCallBlock)
.map((c) => ({
type: 'tool_call',
id: c.toolCallId,
name: c.toolName,
args: jsonParse<Record<string, unknown>>(c.input, { fallbackValue: {} }),
}));
const nonToolContent = lcContent.filter((c) => c.type !== 'tool_call');
return new LangchainMessages.AIMessage({
content: nonToolContent,
@@ -7,5 +7,10 @@
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/examples/**"]
"exclude": [
"src/**/__tests__/**",
"src/**/*.test.ts",
"src/examples/**",
"integration-tests/**/*.ts"
]
}
@@ -10,5 +10,5 @@
"outDir": "./dist_examples",
"tsBuildInfoFile": "dist_examples/build.tsbuildinfo"
},
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "integration-tests/**/*.ts"]
}
+1 -1
View File
@@ -13,6 +13,6 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"],
"include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts", "integration-tests/**/*.ts"],
"references": [{ "path": "../../workflow/tsconfig.build.esm.json" }]
}