feat: Add metering for builder (no-changelog) (#19842)

This commit is contained in:
Mutasem Aldmour 2025-09-25 09:40:55 +02:00 committed by GitHub
parent 0b7db24070
commit c449e9e9c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2893 additions and 382 deletions

View File

@ -8,4 +8,9 @@ export default defineConfig(nodeConfig, {
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/naming-convention': 'warn',
},
}, {
files: ['./src/test/**/*.ts', './**/*.test.ts'],
rules: {
'@typescript-eslint/no-unsafe-assignment': 'warn',
},
});

View File

@ -1,95 +1,119 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatAnthropic } from '@langchain/anthropic';
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
import { MemorySaver } from '@langchain/langgraph';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { Client } from 'langsmith';
import { AiAssistantClient, AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import assert from 'assert';
import { Client as TracingClient } from 'langsmith';
import { INodeTypes } from 'n8n-workflow';
import type { IUser, INodeTypeDescription } from 'n8n-workflow';
import { LLMServiceError } from './errors';
import { anthropicClaudeSonnet4, gpt41mini } from './llm-config';
import { WorkflowBuilderAgent, type ChatPayload } from './workflow-builder-agent';
import { LLMServiceError } from '@/errors';
import { anthropicClaudeSonnet4 } from '@/llm-config';
import { SessionManagerService } from '@/session-manager.service';
import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent';
type OnCreditsUpdated = (userId: string, creditsQuota: number, creditsClaimed: number) => void;
@Service()
export class AiWorkflowBuilderService {
private parsedNodeTypes: INodeTypeDescription[] = [];
private llmSimpleTask: BaseChatModel | undefined;
private llmComplexTask: BaseChatModel | undefined;
private tracingClient: Client | undefined;
private checkpointer = new MemorySaver();
private agent: WorkflowBuilderAgent | undefined;
private sessionManager: SessionManagerService;
constructor(
private readonly nodeTypes: INodeTypes,
private readonly client?: AiAssistantClient,
private readonly logger?: Logger,
private readonly instanceUrl?: string,
private readonly onCreditsUpdated?: OnCreditsUpdated,
) {
this.parsedNodeTypes = this.getNodeTypes();
this.sessionManager = new SessionManagerService(this.parsedNodeTypes, logger);
}
private async setupModels(user?: IUser) {
try {
if (this.llmSimpleTask && this.llmComplexTask) {
return;
}
private static async getAnthropicClaudeModel({
baseUrl,
authHeaders = {},
apiKey = '-',
}: {
baseUrl?: string;
authHeaders?: Record<string, string>;
apiKey?: string;
} = {}): Promise<ChatAnthropic> {
return await anthropicClaudeSonnet4({
baseUrl,
apiKey,
headers: {
...authHeaders,
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
}
private async getApiProxyAuthHeaders(user: IUser, useDeprecatedCredentials = false) {
assert(this.client);
let authHeaders: { Authorization: string };
if (useDeprecatedCredentials) {
const authResponse = await this.client.generateApiProxyCredentials(user);
authHeaders = { Authorization: authResponse.apiKey };
} else {
const authResponse = await this.client.getBuilderApiProxyToken(user);
authHeaders = {
Authorization: `${authResponse.tokenType} ${authResponse.accessToken}`,
};
}
return authHeaders;
}
private async setupModels(
user: IUser,
useDeprecatedCredentials = false,
): Promise<{
anthropicClaude: ChatAnthropic;
tracingClient?: TracingClient;
authHeaders?: { Authorization: string };
}> {
try {
// If client is provided, use it for API proxy
if (this.client && user) {
const authHeaders = await this.client.generateApiProxyCredentials(user);
if (this.client) {
const authHeaders = await this.getApiProxyAuthHeaders(user, useDeprecatedCredentials);
// Extract baseUrl from client configuration
const baseUrl = this.client.getApiProxyBaseUrl();
this.llmSimpleTask = await gpt41mini({
baseUrl: baseUrl + '/openai',
// When using api-proxy the key will be populated automatically, we just need to pass a placeholder
apiKey: '-',
headers: {
Authorization: authHeaders.apiKey,
},
});
this.llmComplexTask = await anthropicClaudeSonnet4({
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
baseUrl: baseUrl + '/anthropic',
apiKey: '-',
headers: {
Authorization: authHeaders.apiKey,
'anthropic-beta': 'prompt-caching-2024-07-31',
},
authHeaders,
});
this.tracingClient = new Client({
const tracingClient = new TracingClient({
apiKey: '-',
apiUrl: baseUrl + '/langsmith',
autoBatchTracing: false,
traceBatchConcurrency: 1,
fetchOptions: {
headers: {
Authorization: authHeaders.apiKey,
...authHeaders,
},
},
});
return;
return { tracingClient, anthropicClaude, authHeaders };
}
// If base URL is not set, use environment variables
this.llmSimpleTask = await gpt41mini({
apiKey: process.env.N8N_AI_OPENAI_API_KEY ?? '',
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '',
});
this.llmComplexTask = await anthropicClaudeSonnet4({
apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '',
headers: {
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
return { anthropicClaude };
} catch (error) {
const llmError = new LLMServiceError('Failed to connect to LLM Provider', {
const errorMessage = error instanceof Error ? `: ${error.message}` : '';
const llmError = new LLMServiceError(`Failed to connect to LLM Provider${errorMessage}`, {
cause: error,
tags: {
hasClient: !!this.client,
@ -144,33 +168,57 @@ export class AiWorkflowBuilderService {
return nodeTypes;
}
private async getAgent(user?: IUser) {
if (!this.llmComplexTask || !this.llmSimpleTask) {
await this.setupModels(user);
}
private async getAgent(user: IUser, useDeprecatedCredentials = false) {
const { anthropicClaude, tracingClient, authHeaders } = await this.setupModels(
user,
useDeprecatedCredentials,
);
if (!this.llmComplexTask || !this.llmSimpleTask) {
throw new LLMServiceError('Failed to initialize LLM models');
}
this.agent ??= new WorkflowBuilderAgent({
const agent = new WorkflowBuilderAgent({
parsedNodeTypes: this.parsedNodeTypes,
// We use Sonnet both for simple and complex tasks
llmSimpleTask: this.llmComplexTask,
llmComplexTask: this.llmComplexTask,
llmSimpleTask: anthropicClaude,
llmComplexTask: anthropicClaude,
logger: this.logger,
checkpointer: this.checkpointer,
tracer: this.tracingClient
? new LangChainTracer({ client: this.tracingClient, projectName: 'n8n-workflow-builder' })
checkpointer: this.sessionManager.getCheckpointer(),
tracer: tracingClient
? new LangChainTracer({ client: tracingClient, projectName: 'n8n-workflow-builder' })
: undefined,
instanceUrl: this.instanceUrl,
onGenerationSuccess: async () => {
if (!useDeprecatedCredentials) {
await this.onGenerationSuccess(user, authHeaders);
}
},
});
return this.agent;
return agent;
}
async *chat(payload: ChatPayload, user?: IUser, abortSignal?: AbortSignal) {
const agent = await this.getAgent(user);
private async onGenerationSuccess(
user?: IUser,
authHeaders?: { Authorization: string },
): Promise<void> {
try {
if (this.client) {
assert(authHeaders, 'Auth headers must be set when AI Assistant Service client is used');
assert(user);
const creditsInfo = await this.client.markBuilderSuccess(user, authHeaders);
// Call the callback with the credits info from the response
if (this.onCreditsUpdated && user.id && creditsInfo) {
this.onCreditsUpdated(user.id, creditsInfo.creditsQuota, creditsInfo.creditsClaimed);
}
}
} catch (error: unknown) {
if (error instanceof Error) {
this.logger?.error(`Unable to mark generation success ${error.message}`, { error });
}
}
}
async *chat(payload: ChatPayload, user: IUser, abortSignal?: AbortSignal) {
const agent = await this.getAgent(user, payload.useDeprecatedCredentials);
for await (const output of agent.chat(payload, user?.id?.toString(), abortSignal)) {
yield output;
@ -178,7 +226,21 @@ export class AiWorkflowBuilderService {
}
async getSessions(workflowId: string | undefined, user?: IUser) {
const agent = await this.getAgent(user);
return await agent.getSessions(workflowId, user?.id?.toString());
const userId = user?.id?.toString();
return await this.sessionManager.getSessions(workflowId, userId);
}
async getBuilderInstanceCredits(
user: IUser,
): Promise<AiAssistantSDK.BuilderInstanceCreditsResponse> {
if (this.client) {
return await this.client.getBuilderInstanceCredits(user);
}
// if using env variables directly instead of ai proxy service
return {
creditsQuota: -1,
creditsClaimed: 0,
};
}
}

View File

@ -0,0 +1,88 @@
import { RunnableConfig } from '@langchain/core/runnables';
import { MemorySaver } from '@langchain/langgraph';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { INodeTypeDescription } from 'n8n-workflow';
import { formatMessages } from '@/utils/stream-processor';
import { getBuilderToolsForDisplay } from './tools/builder-tools';
import { isLangchainMessagesArray, LangchainMessage, Session } from './types/sessions';
@Service()
export class SessionManagerService {
private checkpointer: MemorySaver;
constructor(
private readonly parsedNodeTypes: INodeTypeDescription[],
private readonly logger?: Logger,
) {
this.checkpointer = new MemorySaver();
}
/**
* Generate a thread ID for a given workflow and user
*/
static generateThreadId(workflowId?: string, userId?: string): string {
return workflowId
? `workflow-${workflowId}-user-${userId ?? new Date().getTime()}`
: crypto.randomUUID();
}
/**
* Get the checkpointer instance
*/
getCheckpointer(): MemorySaver {
return this.checkpointer;
}
/**
* Get sessions for a given workflow and user
*/
async getSessions(
workflowId: string | undefined,
userId: string | undefined,
): Promise<{ sessions: Session[] }> {
// For now, we'll return the current session if we have a workflowId
// MemorySaver doesn't expose a way to list all threads, so we'll need to
// track this differently if we want to list all sessions
const sessions: Session[] = [];
if (workflowId) {
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
const threadConfig: RunnableConfig = {
configurable: {
thread_id: threadId,
},
};
try {
// Try to get the checkpoint for this thread
const checkpoint = await this.checkpointer.getTuple(threadConfig);
if (checkpoint?.checkpoint) {
const rawMessages = checkpoint.checkpoint.channel_values?.messages;
const messages: LangchainMessage[] = isLangchainMessagesArray(rawMessages)
? rawMessages
: [];
sessions.push({
sessionId: threadId,
messages: formatMessages(
messages,
getBuilderToolsForDisplay({
nodeTypes: this.parsedNodeTypes,
}),
),
lastUpdated: checkpoint.checkpoint.ts,
});
}
} catch (error) {
// Thread doesn't exist yet
this.logger?.debug('No session found for workflow:', { workflowId, error });
}
}
return { sessions };
}
}

View File

@ -0,0 +1,716 @@
import { ChatAnthropic } from '@langchain/anthropic';
import { MemorySaver } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
import type { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { mock } from 'jest-mock-extended';
import { Client as TracingClient } from 'langsmith';
import type { IUser, INodeTypes, INodeTypeDescription } from 'n8n-workflow';
import { AiWorkflowBuilderService } from '@/ai-workflow-builder-agent.service';
import { LLMServiceError } from '@/errors';
import { anthropicClaudeSonnet4 } from '@/llm-config';
import { SessionManagerService } from '@/session-manager.service';
import { formatMessages } from '@/utils/stream-processor';
import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent';
// Mock dependencies
jest.mock('@langchain/anthropic');
jest.mock('@langchain/langgraph');
jest.mock('langsmith');
jest.mock('@/workflow-builder-agent');
jest.mock('@/session-manager.service');
jest.mock('@/llm-config', () => ({
anthropicClaudeSonnet4: jest.fn(),
}));
jest.mock('@/utils/stream-processor', () => ({
formatMessages: jest.fn(),
}));
const MockedChatAnthropic = ChatAnthropic as jest.MockedClass<typeof ChatAnthropic>;
const MockedMemorySaver = MemorySaver as jest.MockedClass<typeof MemorySaver>;
const MockedTracingClient = TracingClient as jest.MockedClass<typeof TracingClient>;
const MockedWorkflowBuilderAgent = WorkflowBuilderAgent as jest.MockedClass<
typeof WorkflowBuilderAgent
>;
const MockedSessionManagerService = SessionManagerService as jest.MockedClass<
typeof SessionManagerService
>;
const anthropicClaudeSonnet4Mock = anthropicClaudeSonnet4 as jest.MockedFunction<
typeof anthropicClaudeSonnet4
>;
const formatMessagesMock = formatMessages as jest.MockedFunction<typeof formatMessages>;
describe('AiWorkflowBuilderService', () => {
let service: AiWorkflowBuilderService;
let mockNodeTypes: INodeTypes;
let mockClient: AiAssistantClient;
let mockLogger: Logger;
let mockUser: IUser;
let mockChatAnthropic: ChatAnthropic;
let mockTracingClient: TracingClient;
let mockMemorySaver: MemorySaver;
let mockSessionManager: SessionManagerService;
let mockOnCreditsUpdated: jest.Mock;
const mockNodeTypeDescriptions: INodeTypeDescription[] = [
{
name: 'TestNode',
displayName: 'Test Node',
description: 'A test node',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [],
group: ['transform'],
} as INodeTypeDescription,
{
name: 'HiddenNode',
displayName: 'Hidden Node',
description: 'A hidden node',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [],
group: ['transform'],
hidden: true,
} as INodeTypeDescription,
{
name: '@n8n/n8n-nodes-langchain.toolVectorStore',
displayName: 'Tool Vector Store',
description: 'An ignored tool node',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [],
group: ['transform'],
} as INodeTypeDescription,
];
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock node types
mockNodeTypes = mock<INodeTypes>();
(mockNodeTypes.getKnownTypes as jest.Mock).mockReturnValue({
TestNode: { type: {} },
HiddenNode: { type: {} },
'@n8n/n8n-nodes-langchain.toolVectorStore': { type: {} },
});
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation((name: string) => {
const nodeType = mockNodeTypeDescriptions.find((n) => n.name === name);
if (nodeType) {
return { description: nodeType };
}
throw new Error(`Node type ${name} not found`);
});
// Mock AI assistant client
mockClient = mock<AiAssistantClient>();
(mockClient.generateApiProxyCredentials as jest.Mock).mockResolvedValue({
apiKey: 'test-api-key',
});
(mockClient.getBuilderApiProxyToken as jest.Mock).mockResolvedValue({
tokenType: 'Bearer',
accessToken: 'test-access-token',
});
(mockClient.getApiProxyBaseUrl as jest.Mock).mockReturnValue('https://api.example.com');
(mockClient.markBuilderSuccess as jest.Mock).mockResolvedValue({
creditsQuota: 10,
creditsClaimed: 1,
});
// Mock logger
mockLogger = mock<Logger>();
// Mock user
mockUser = mock<IUser>();
mockUser.id = 'test-user-id';
// Mock ChatAnthropic
mockChatAnthropic = mock<ChatAnthropic>();
MockedChatAnthropic.mockImplementation(() => mockChatAnthropic);
// Mock TracingClient
mockTracingClient = mock<TracingClient>();
MockedTracingClient.mockImplementation(() => mockTracingClient);
// Mock MemorySaver
mockMemorySaver = mock<MemorySaver>();
MockedMemorySaver.mockImplementation(() => mockMemorySaver);
// Mock SessionManagerService
mockSessionManager = mock<SessionManagerService>();
(mockSessionManager.getCheckpointer as jest.Mock).mockReturnValue(mockMemorySaver);
MockedSessionManagerService.mockImplementation(() => mockSessionManager);
// Mock WorkflowBuilderAgent
MockedWorkflowBuilderAgent.mockImplementation(() => {
const mockAgent = mock<WorkflowBuilderAgent>();
(mockAgent.chat as jest.Mock).mockImplementation(async function* () {
yield { messages: [{ role: 'assistant', type: 'message', text: 'Test response' }] };
});
return mockAgent;
});
anthropicClaudeSonnet4Mock.mockResolvedValue(mockChatAnthropic);
// Mock onCreditsUpdated callback
mockOnCreditsUpdated = jest.fn();
// Create service instance
service = new AiWorkflowBuilderService(
mockNodeTypes,
mockClient,
mockLogger,
'https://n8n.example.com',
mockOnCreditsUpdated,
);
});
describe('constructor', () => {
it('should initialize with provided dependencies', () => {
const testService = new AiWorkflowBuilderService(
mockNodeTypes,
mockClient,
mockLogger,
'https://test.com',
mockOnCreditsUpdated,
);
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
expect(mockNodeTypes.getKnownTypes).toHaveBeenCalled();
expect(MockedSessionManagerService).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ name: 'TestNode' })]),
mockLogger,
);
});
it('should initialize without optional dependencies', () => {
const testService = new AiWorkflowBuilderService(mockNodeTypes);
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
expect(MockedSessionManagerService).toHaveBeenCalledWith(expect.any(Array), undefined);
});
it('should filter out ignored and hidden node types', () => {
// The service filters ignored types at the filter stage, not at getByNameAndVersion stage
// Hidden nodes are filtered out after being retrieved in the filter() call
expect(mockNodeTypes.getKnownTypes).toHaveBeenCalled();
expect(mockNodeTypes.getByNameAndVersion).toHaveBeenCalledWith('TestNode');
// Hidden nodes are still retrieved but filtered out later, so this call happens
expect(mockNodeTypes.getByNameAndVersion).toHaveBeenCalledWith('HiddenNode');
// Ignored types are filtered out before getByNameAndVersion call
expect(mockNodeTypes.getByNameAndVersion).not.toHaveBeenCalledWith(
'@n8n/n8n-nodes-langchain.toolVectorStore',
);
});
it('should handle errors when getting node types', () => {
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation(() => {
throw new Error('Node type error');
});
// Should not throw when constructing, but should log error
const testService = new AiWorkflowBuilderService(mockNodeTypes, mockClient, mockLogger);
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
expect(mockLogger.error).toHaveBeenCalledWith('Error getting node type', expect.any(Object));
});
it('should handle tool node merging correctly when calling chat', async () => {
// Setup node types that include tool nodes
(mockNodeTypes.getKnownTypes as jest.Mock).mockReturnValue({
HttpRequestTool: { type: {} },
HttpRequest: { type: {} },
});
const httpRequestToolNode: INodeTypeDescription = {
name: 'HttpRequestTool',
displayName: 'HTTP Request Tool',
description: 'Tool version of HTTP Request',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [
{
name: 'toolProp',
type: 'string',
displayName: '',
default: undefined,
},
],
group: ['transform'],
};
const httpRequestNode: INodeTypeDescription = {
name: 'HttpRequest',
displayName: 'HTTP Request',
description: 'Regular HTTP Request node',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [
{
name: 'regularProp',
type: 'string',
displayName: '',
default: undefined,
},
],
group: ['transform'],
};
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation((name: string) => {
if (name === 'HttpRequestTool') return { description: httpRequestToolNode };
if (name === 'HttpRequest') return { description: httpRequestNode };
throw new Error(`Node type ${name} not found`);
});
// Get the mocked WorkflowBuilderAgent constructor
const MockedWorkflowBuilderAgent = WorkflowBuilderAgent as jest.MockedClass<
typeof WorkflowBuilderAgent
>;
// Clear previous calls and setup return value
MockedWorkflowBuilderAgent.mockClear();
const testService = new AiWorkflowBuilderService(mockNodeTypes, mockClient, mockLogger);
const payload: ChatPayload = {
message: 'Create a workflow',
workflowContext: {
currentWorkflow: { id: 'test-workflow' },
},
useDeprecatedCredentials: false,
};
// Call chat to trigger agent creation
const generator = testService.chat(payload, mockUser);
await generator.next();
// Verify WorkflowBuilderAgent was called with merged node types
expect(MockedWorkflowBuilderAgent).toHaveBeenCalledWith(
expect.objectContaining({
parsedNodeTypes: expect.arrayContaining([
expect.objectContaining({
name: 'HttpRequestTool',
displayName: 'HTTP Request Tool',
// Should have tool properties (since tool node overrides regular node in merge)
properties: [
{ name: 'toolProp', type: 'string', displayName: '', default: undefined },
],
}),
]),
}),
);
// Also verify that the merged node has the correct structure
const actualCall = MockedWorkflowBuilderAgent.mock.calls[0][0];
const parsedNodeTypes = actualCall.parsedNodeTypes;
// Should have the merged HttpRequestTool node and keep the separate HttpRequest node
const toolNode = parsedNodeTypes.find(
(node: INodeTypeDescription) => node.name === 'HttpRequestTool',
);
const regularNode = parsedNodeTypes.find(
(node: INodeTypeDescription) => node.name === 'HttpRequest',
);
expect(toolNode).toBeDefined();
expect(toolNode?.displayName).toBe('HTTP Request Tool');
expect(toolNode?.properties).toEqual([
{
name: 'toolProp',
type: 'string',
displayName: '',
default: undefined,
},
]);
// The regular node should still exist separately (not merged into tool)
expect(regularNode).toBeDefined();
expect(regularNode?.displayName).toBe('HTTP Request');
});
});
describe('chat', () => {
let mockPayload: ChatPayload;
beforeEach(() => {
mockPayload = {
message: 'Create a simple workflow',
workflowContext: {
currentWorkflow: { id: 'test-workflow' },
},
useDeprecatedCredentials: false,
};
});
it('should yield results from agent chat', async () => {
const generator = service.chat(mockPayload, mockUser);
const result = await generator.next();
expect(result.value).toEqual({
messages: [{ role: 'assistant', type: 'message', text: 'Test response' }],
});
expect(result.done).toBe(false);
});
it('should pass abort signal to agent', async () => {
const abortController = new AbortController();
const generator = service.chat(mockPayload, mockUser, abortController.signal);
await generator.next();
// Verify that the agent's chat method was called with the abort signal
const mockAgentInstance = MockedWorkflowBuilderAgent.mock.results[0]
.value as WorkflowBuilderAgent;
expect(mockAgentInstance.chat).toHaveBeenCalledWith(
mockPayload,
'test-user-id',
abortController.signal,
);
});
it('should handle deprecated credentials', async () => {
const payloadWithDeprecatedCredentials = {
...mockPayload,
useDeprecatedCredentials: true,
};
const generator = service.chat(payloadWithDeprecatedCredentials, mockUser);
await generator.next();
// Verify that the deprecated credentials flow was used
expect(mockClient.generateApiProxyCredentials).toHaveBeenCalledWith(mockUser);
});
it('should create WorkflowBuilderAgent with correct configuration when using client', async () => {
const generator = service.chat(mockPayload, mockUser);
await generator.next();
// Verify WorkflowBuilderAgent was called
expect(MockedWorkflowBuilderAgent).toHaveBeenCalled();
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
// Verify key configuration properties
expect(config).toHaveProperty('parsedNodeTypes');
expect(config).toHaveProperty('instanceUrl', 'https://n8n.example.com');
expect(config).toHaveProperty('onGenerationSuccess');
expect(config).toHaveProperty('tracer');
expect(config).toHaveProperty('checkpointer', mockMemorySaver);
expect(config.parsedNodeTypes).toBeInstanceOf(Array);
expect(config.onGenerationSuccess).toBeInstanceOf(Function);
// Verify checkpointer comes from SessionManagerService
expect(mockSessionManager.getCheckpointer).toHaveBeenCalled();
});
it('should create WorkflowBuilderAgent without tracer when no client', async () => {
const serviceWithoutClient = new AiWorkflowBuilderService(
mockNodeTypes,
undefined,
mockLogger,
);
const generator = serviceWithoutClient.chat(mockPayload, mockUser);
await generator.next();
// Verify WorkflowBuilderAgent was called
expect(MockedWorkflowBuilderAgent).toHaveBeenCalled();
const config =
MockedWorkflowBuilderAgent.mock.calls[MockedWorkflowBuilderAgent.mock.calls.length - 1][0];
// Verify key configuration properties
expect(config).toHaveProperty('parsedNodeTypes');
expect(config).toHaveProperty('instanceUrl', undefined);
expect(config).toHaveProperty('onGenerationSuccess');
expect(config).toHaveProperty('tracer', undefined);
expect(config).toHaveProperty('checkpointer');
expect(config.parsedNodeTypes).toBeInstanceOf(Array);
expect(config.onGenerationSuccess).toBeInstanceOf(Function);
});
it('should throw LLMServiceError when model setup fails', async () => {
const testError = new Error('Model setup failed');
anthropicClaudeSonnet4Mock.mockRejectedValue(testError);
const generator = service.chat(mockPayload, mockUser);
await expect(generator.next()).rejects.toThrow(LLMServiceError);
});
it('should include error details in LLMServiceError', async () => {
const testError = new Error('Specific error message');
anthropicClaudeSonnet4Mock.mockRejectedValue(testError);
const generator = service.chat(mockPayload, mockUser);
try {
await generator.next();
} catch (error) {
expect(error).toBeInstanceOf(LLMServiceError);
expect((error as LLMServiceError).message).toContain('Specific error message');
expect((error as LLMServiceError).tags).toMatchObject({
hasClient: true,
hasUser: true,
});
}
});
it('should use environment variables when no client provided', async () => {
const serviceWithoutClient = new AiWorkflowBuilderService(mockNodeTypes);
process.env.N8N_AI_ANTHROPIC_KEY = 'test-env-key';
const generator = serviceWithoutClient.chat(mockPayload, mockUser);
await generator.next();
expect(anthropicClaudeSonnet4Mock).toHaveBeenCalledWith({
baseUrl: undefined,
apiKey: 'test-env-key',
headers: {
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
delete process.env.N8N_AI_ANTHROPIC_KEY;
});
it('should call onGenerationSuccess callback when not using deprecated credentials', async () => {
const generator = service.chat(mockPayload, mockUser);
await generator.next();
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
// Call the onGenerationSuccess callback
await config.onGenerationSuccess!();
expect(mockClient.markBuilderSuccess).toHaveBeenCalledWith(mockUser, {
Authorization: 'Bearer test-access-token',
});
});
it('should call onCreditsUpdated callback after markBuilderSuccess', async () => {
const generator = service.chat(mockPayload, mockUser);
await generator.next();
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
// Call the onGenerationSuccess callback
await config.onGenerationSuccess!();
// Verify callback was called with correct parameters
expect(mockOnCreditsUpdated).toHaveBeenCalledWith('test-user-id', 10, 1);
});
it('should not call markBuilderSuccess when using deprecated credentials', async () => {
const payloadWithDeprecatedCredentials = {
...mockPayload,
useDeprecatedCredentials: true,
};
const generator = service.chat(payloadWithDeprecatedCredentials, mockUser);
await generator.next();
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
// Call the onGenerationSuccess callback
await config.onGenerationSuccess!();
// Should not call markBuilderSuccess for deprecated credentials
expect(mockClient.markBuilderSuccess).not.toHaveBeenCalled();
});
});
describe('getSessions', () => {
beforeEach(() => {
formatMessagesMock.mockReturnValue([
{ role: 'user', type: 'message', text: 'Hello' },
{ role: 'assistant', type: 'message', text: 'Hi there!' },
]);
// Reset mocks for each test
(mockMemorySaver.getTuple as jest.Mock).mockReset();
(mockSessionManager.getSessions as jest.Mock).mockReset();
});
it('should return empty sessions when no workflowId provided', async () => {
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
const result = await service.getSessions(undefined, mockUser);
expect(result.sessions).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(undefined, 'test-user-id');
});
it('should return session when workflowId exists', async () => {
const workflowId = 'test-workflow';
const mockSession = {
sessionId: 'workflow-test-workflow-user-test-user-id',
messages: [
{ role: 'user', type: 'message', text: 'Hello' },
{ role: 'assistant', type: 'message', text: 'Hi there!' },
],
lastUpdated: '2023-12-01T12:00:00Z',
};
// Mock SessionManagerService to return the session
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
sessions: [mockSession],
});
const result = await service.getSessions(workflowId, mockUser);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: 'workflow-test-workflow-user-test-user-id',
lastUpdated: '2023-12-01T12:00:00Z',
});
expect(result.sessions[0].messages).toHaveLength(2);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
});
it('should handle missing checkpoint gracefully', async () => {
const workflowId = 'non-existent-workflow';
// Mock SessionManagerService to return empty sessions
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
const result = await service.getSessions(workflowId, mockUser);
expect(result.sessions).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
});
it('should handle checkpoint without messages', async () => {
const workflowId = 'test-workflow';
const mockSession = {
sessionId: 'workflow-test-workflow-user-test-user-id',
messages: [],
lastUpdated: '2023-12-01T12:00:00Z',
};
// Mock SessionManagerService to return session with empty messages
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
sessions: [mockSession],
});
const result = await service.getSessions(workflowId, mockUser);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
});
it('should handle checkpoint with null messages', async () => {
const workflowId = 'test-workflow';
const mockSession = {
sessionId: 'workflow-test-workflow-user-test-user-id',
messages: [],
lastUpdated: '2023-12-01T12:00:00Z',
};
// Mock SessionManagerService to return session with empty messages
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
sessions: [mockSession],
});
const result = await service.getSessions(workflowId, mockUser);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
});
it('should work without user', async () => {
const workflowId = 'test-workflow';
// Mock SessionManagerService to return empty sessions
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
const result = await service.getSessions(workflowId);
expect(result.sessions).toEqual([]);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, undefined);
});
});
describe('integration tests', () => {
it('should handle complete workflow from chat to session retrieval', async () => {
const workflowId = 'integration-test-workflow';
const mockPayload: ChatPayload = {
message: 'Create a workflow with HTTP request',
workflowContext: {
currentWorkflow: { id: workflowId },
},
useDeprecatedCredentials: false,
};
// First, simulate a chat session
const generator = service.chat(mockPayload, mockUser);
await generator.next();
// Mock the session to simulate that it was saved
const mockSession = {
sessionId: 'workflow-integration-test-workflow-user-test-user-id',
messages: [
{ role: 'user', content: 'Create a workflow with HTTP request' },
{
role: 'assistant',
content: 'I will create a workflow with an HTTP request node for you.',
},
],
lastUpdated: new Date().toISOString(),
};
// Mock SessionManagerService to return the session
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
sessions: [mockSession],
});
// Then retrieve the session
const sessions = await service.getSessions(workflowId, mockUser);
expect(sessions.sessions).toHaveLength(1);
expect(sessions.sessions[0].sessionId).toBe(
'workflow-integration-test-workflow-user-test-user-id',
);
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
});
});
describe('getBuilderInstanceCredits', () => {
it('should return builder instance credits when client is available', async () => {
const expectedCredits = {
creditsQuota: 100,
creditsClaimed: 25,
};
(mockClient.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
const result = await service.getBuilderInstanceCredits(mockUser);
expect(result).toEqual(expectedCredits);
expect(mockClient.getBuilderInstanceCredits).toHaveBeenCalledWith(mockUser);
});
it('should return default values when client is not configured', async () => {
const serviceWithoutClient = new AiWorkflowBuilderService(mockNodeTypes);
const result = await serviceWithoutClient.getBuilderInstanceCredits(mockUser);
expect(result).toEqual({
creditsQuota: -1,
creditsClaimed: 0,
});
});
});
});

View File

@ -0,0 +1,368 @@
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
import { MemorySaver } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
import { mock, mockClear } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { SessionManagerService } from '../session-manager.service';
import { getBuilderToolsForDisplay } from '../tools/builder-tools';
import * as streamProcessor from '../utils/stream-processor';
jest.mock('@langchain/langgraph');
jest.mock('../utils/stream-processor');
jest.mock('../tools/builder-tools', () => ({
getBuilderToolsForDisplay: jest.fn().mockReturnValue([]),
}));
describe('SessionManagerService', () => {
let service: SessionManagerService;
let mockLogger: ReturnType<typeof mock<Logger>>;
let mockMemorySaver: ReturnType<typeof mock<MemorySaver>>;
let mockParsedNodeTypes: INodeTypeDescription[];
let formatMessagesSpy: jest.SpyInstance;
const MockedMemorySaver = MemorySaver as jest.MockedClass<typeof MemorySaver>;
beforeEach(() => {
mockLogger = mock<Logger>();
mockMemorySaver = mock<MemorySaver>();
mockParsedNodeTypes = [
{
displayName: 'HTTP Request',
name: 'n8n-nodes-base.httpRequest',
group: ['transform'],
version: 1,
description: 'Makes HTTP requests',
defaults: { name: 'HTTP Request' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
];
MockedMemorySaver.mockImplementation(() => mockMemorySaver);
// Mock formatMessages to return a simple formatted array
formatMessagesSpy = jest.spyOn(streamProcessor, 'formatMessages').mockImplementation(() => [
{ role: 'human', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
service = new SessionManagerService(mockParsedNodeTypes, mockLogger);
});
afterEach(() => {
mockClear(mockLogger);
mockClear(mockMemorySaver);
jest.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with provided dependencies', () => {
expect(service).toBeDefined();
expect(MockedMemorySaver).toHaveBeenCalled();
});
it('should work without logger', () => {
const serviceWithoutLogger = new SessionManagerService(mockParsedNodeTypes);
expect(serviceWithoutLogger).toBeDefined();
});
});
describe('generateThreadId', () => {
it('should generate thread ID with workflowId and userId', () => {
const threadId = SessionManagerService.generateThreadId('workflow-123', 'user-456');
expect(threadId).toBe('workflow-workflow-123-user-user-456');
});
it('should generate thread ID with workflowId but without userId', () => {
const threadId = SessionManagerService.generateThreadId('workflow-123');
expect(threadId).toMatch(/^workflow-workflow-123-user-\d+$/);
});
it('should generate random UUID when no workflowId provided', () => {
const threadId = SessionManagerService.generateThreadId();
// UUID v4 format check
expect(threadId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('should generate random UUID when workflowId is undefined', () => {
const threadId = SessionManagerService.generateThreadId(undefined, 'user-123');
expect(threadId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});
describe('getCheckpointer', () => {
it('should return the checkpointer instance', () => {
const checkpointer = service.getCheckpointer();
expect(checkpointer).toBe(mockMemorySaver);
});
it('should always return the same checkpointer instance', () => {
const checkpointer1 = service.getCheckpointer();
const checkpointer2 = service.getCheckpointer();
expect(checkpointer1).toBe(checkpointer2);
});
});
describe('getSessions', () => {
it('should return empty sessions when no workflowId provided', async () => {
const result = await service.getSessions(undefined, 'user-123');
expect(result).toEqual({ sessions: [] });
expect(mockMemorySaver.getTuple).not.toHaveBeenCalled();
});
it('should return session when checkpoint exists', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: [new HumanMessage('Hello'), new AIMessage('Hi there!')],
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: 'workflow-test-workflow-user-test-user',
lastUpdated: '2023-12-01T12:00:00Z',
});
expect(result.sessions[0].messages).toEqual([
{ role: 'human', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
configurable: {
thread_id: 'workflow-test-workflow-user-test-user',
},
});
});
it('should handle checkpoint without messages', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const mockCheckpoint = {
checkpoint: {
channel_values: {},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
// Since there are no messages, formatMessages will be called with empty array
formatMessagesSpy.mockReturnValue([]);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toEqual([]);
});
it('should handle checkpoint with null channel_values', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const mockCheckpoint = {
checkpoint: {
channel_values: null,
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
formatMessagesSpy.mockReturnValue([]);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toEqual([]);
});
it('should handle invalid messages gracefully', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: [
{ invalid: 'object' }, // Invalid message format
'not an object', // Invalid type
null, // Null value
],
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
formatMessagesSpy.mockReturnValue([]);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toEqual([]);
expect(formatMessagesSpy).toHaveBeenCalledWith([], expect.anything());
});
it('should handle missing checkpoint gracefully', async () => {
const workflowId = 'non-existent-workflow';
const userId = 'test-user';
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(null);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toEqual([]);
});
it('should handle checkpoint errors gracefully', async () => {
const workflowId = 'error-workflow';
const userId = 'test-user';
const error = new Error('Failed to get checkpoint');
(mockMemorySaver.getTuple as jest.Mock).mockRejectedValue(error);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toEqual([]);
expect(mockLogger.debug).toHaveBeenCalledWith('No session found for workflow:', {
workflowId,
error,
});
});
it('should work without userId', async () => {
const workflowId = 'test-workflow';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: [new HumanMessage('Hello')],
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
const result = await service.getSessions(workflowId, undefined);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].sessionId).toMatch(/^workflow-test-workflow-user-\d+$/);
});
it('should pass correct parameters to formatMessages', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const messages = [new HumanMessage('Test'), new AIMessage('Response')];
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages,
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
await service.getSessions(workflowId, userId);
expect(formatMessagesSpy).toHaveBeenCalledWith(messages, expect.anything());
expect(getBuilderToolsForDisplay).toHaveBeenCalledWith({
nodeTypes: mockParsedNodeTypes,
});
});
it('should handle ToolMessage in messages', async () => {
const workflowId = 'test-workflow';
const userId = 'test-user';
const messages = [
new HumanMessage('Test'),
new AIMessage({
content: 'Let me help',
tool_calls: [{ name: 'test_tool', args: {}, id: 'tool-1' }],
}),
new ToolMessage({ content: 'Tool result', tool_call_id: 'tool-1', name: 'test_tool' }),
];
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages,
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
formatMessagesSpy.mockReturnValue([
{ role: 'human', content: 'Test' },
{ role: 'assistant', content: 'Let me help', tool_calls: [{ name: 'test_tool' }] },
{ role: 'tool', content: 'Tool result', name: 'test_tool' },
]);
const result = await service.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toHaveLength(3);
expect(formatMessagesSpy).toHaveBeenCalledWith(messages, expect.anything());
});
it('should handle empty workflowId string', async () => {
const result = await service.getSessions('', 'user-123');
expect(result).toEqual({ sessions: [] });
expect(mockMemorySaver.getTuple).not.toHaveBeenCalled();
});
});
describe('integration with other components', () => {
it('should use parsed node types from constructor', async () => {
const customNodeTypes: INodeTypeDescription[] = [
{
displayName: 'Custom Node',
name: 'custom.node',
group: ['organization'],
version: 1,
description: 'Custom test node',
defaults: { name: 'Custom' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
];
const customService = new SessionManagerService(customNodeTypes, mockLogger);
const workflowId = 'test-workflow';
const userId = 'test-user';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: [new HumanMessage('Test')],
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
await customService.getSessions(workflowId, userId);
// Verify that the custom node types are used
expect(formatMessagesSpy).toHaveBeenCalledWith(expect.anything(), expect.anything());
expect(getBuilderToolsForDisplay).toHaveBeenCalledWith({
nodeTypes: customNodeTypes,
});
});
});
});

View File

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/require-await */
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { ToolMessage } from '@langchain/core/messages';
import { AIMessage, HumanMessage } from '@langchain/core/messages';
import type { MemorySaver } from '@langchain/langgraph';
import { GraphRecursionError } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
@ -9,16 +7,6 @@ import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { ValidationError } from '@/errors';
import type { StreamOutput } from '@/types/streaming';
import { createStreamProcessor, formatMessages } from '@/utils/stream-processor';
import {
WorkflowBuilderAgent,
type WorkflowBuilderAgentConfig,
type ChatPayload,
} from '@/workflow-builder-agent';
jest.mock('@/tools/add-node.tool', () => ({
createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }),
}));
@ -39,6 +27,9 @@ jest.mock('@/tools/update-node-parameters.tool', () => ({
.fn()
.mockReturnValue({ tool: { name: 'update_node_parameters' } }),
}));
jest.mock('@/tools/get-node-parameter.tool', () => ({
createGetNodeParameterTool: jest.fn().mockReturnValue({ tool: { name: 'get_node_parameter' } }),
}));
jest.mock('@/tools/prompts/main-agent.prompt', () => ({
mainAgentPrompt: {
invoke: jest.fn().mockResolvedValue('mocked prompt'),
@ -70,6 +61,16 @@ Object.defineProperty(global, 'crypto', {
writable: true,
});
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { ValidationError } from '@/errors';
import type { StreamOutput } from '@/types/streaming';
import { createStreamProcessor } from '@/utils/stream-processor';
import {
WorkflowBuilderAgent,
type WorkflowBuilderAgentConfig,
type ChatPayload,
} from '@/workflow-builder-agent';
describe('WorkflowBuilderAgent', () => {
let agent: WorkflowBuilderAgent;
let mockLlmSimple: BaseChatModel;
@ -82,7 +83,6 @@ describe('WorkflowBuilderAgent', () => {
const mockCreateStreamProcessor = createStreamProcessor as jest.MockedFunction<
typeof createStreamProcessor
>;
const mockFormatMessages = formatMessages as jest.MockedFunction<typeof formatMessages>;
beforeEach(() => {
mockLlmSimple = mock<BaseChatModel>({
@ -134,35 +134,6 @@ describe('WorkflowBuilderAgent', () => {
agent = new WorkflowBuilderAgent(config);
});
describe('generateThreadId', () => {
beforeEach(() => {
mockRandomUUID.mockReset();
});
it('should generate thread ID with workflowId and userId', () => {
const workflowId = 'workflow-123';
const userId = 'user-456';
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
expect(threadId).toBe('workflow-workflow-123-user-user-456');
});
it('should generate thread ID with workflowId but without userId', () => {
const workflowId = 'workflow-123';
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId);
expect(threadId).toMatch(/^workflow-workflow-123-user-\d+$/);
});
it('should generate random UUID when no workflowId provided', () => {
const mockUuid = 'test-uuid-1234-5678-9012';
mockRandomUUID.mockReturnValue(mockUuid);
const threadId = WorkflowBuilderAgent.generateThreadId();
expect(mockRandomUUID).toHaveBeenCalled();
expect(threadId).toBe(mockUuid);
});
});
describe('chat method', () => {
let mockPayload: ChatPayload;
@ -172,6 +143,7 @@ describe('WorkflowBuilderAgent', () => {
workflowContext: {
currentWorkflow: { id: 'workflow-123' },
},
useDeprecatedCredentials: false,
};
});
@ -179,6 +151,7 @@ describe('WorkflowBuilderAgent', () => {
const longMessage = 'x'.repeat(MAX_AI_BUILDER_PROMPT_LENGTH + 1);
const payload: ChatPayload = {
message: longMessage,
useDeprecatedCredentials: false,
};
await expect(async () => {
@ -196,6 +169,7 @@ describe('WorkflowBuilderAgent', () => {
const validMessage = 'Create a simple workflow';
const payload: ChatPayload = {
message: validMessage,
useDeprecatedCredentials: false,
};
// Mock the stream processing to return a proper StreamOutput
@ -275,94 +249,4 @@ describe('WorkflowBuilderAgent', () => {
}).rejects.toThrow(unknownError);
});
});
describe('getSessions', () => {
beforeEach(() => {
mockFormatMessages.mockImplementation(
(messages: Array<AIMessage | HumanMessage | ToolMessage>) =>
messages.map((m) => ({ type: m.constructor.name.toLowerCase(), content: m.content })),
);
});
it('should return session for existing workflowId', async () => {
const workflowId = 'workflow-123';
const userId = 'user-456';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: [new HumanMessage('Hello'), new AIMessage('Hi there!')],
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
const result = await agent.getSessions(workflowId, userId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: 'workflow-workflow-123-user-user-456',
lastUpdated: '2023-12-01T12:00:00Z',
});
expect(result.sessions[0].messages).toHaveLength(2);
});
it('should return empty sessions when workflowId is undefined', async () => {
const result = await agent.getSessions(undefined);
expect(result.sessions).toHaveLength(0);
expect(mockCheckpointer.getTuple).not.toHaveBeenCalled();
});
it('should return empty sessions when no checkpoint exists', async () => {
const workflowId = 'workflow-123';
(mockCheckpointer.getTuple as jest.Mock).mockRejectedValue(new Error('Thread not found'));
const result = await agent.getSessions(workflowId);
expect(result.sessions).toHaveLength(0);
expect(mockLogger.debug).toHaveBeenCalledWith('No session found for workflow:', {
workflowId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
error: expect.any(Error),
});
});
it('should handle checkpoint without messages', async () => {
const workflowId = 'workflow-123';
const mockCheckpoint = {
checkpoint: {
channel_values: {},
ts: '2023-12-01T12:00:00Z',
},
};
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
const result = await agent.getSessions(workflowId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toHaveLength(0);
});
it('should handle checkpoint with null messages', async () => {
const workflowId = 'workflow-123';
const mockCheckpoint = {
checkpoint: {
channel_values: {
messages: null,
},
ts: '2023-12-01T12:00:00Z',
},
};
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
const result = await agent.getSessions(workflowId);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].messages).toHaveLength(0);
});
});
});

View File

@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
import type { INode, INodeParameters, INodeTypeDescription } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { NodeTypeNotFoundError, ValidationError } from '../errors';
import { createNodeInstance, generateUniqueName } from './utils/node-creation.utils';
import { calculateNodePosition } from './utils/node-positioning.utils';
@ -13,8 +15,6 @@ import { findNodeType } from './helpers/validation';
import type { AddedNode } from '../types/nodes';
import type { AddNodeOutput, ToolError } from '../types/tools';
const DISPLAY_TITLE = 'Adding nodes';
/**
* Schema for node creation input
*/
@ -81,16 +81,25 @@ function getCustomNodeTitle(
return 'Adding node';
}
export function getAddNodeToolBase(nodeTypes: INodeTypeDescription[]): BuilderToolBase {
return {
toolName: 'add_nodes',
displayTitle: 'Adding nodes',
getCustomDisplayTitle: (input: Record<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
};
}
/**
* Factory function to create the add node tool
*/
export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) {
export function createAddNodeTool(nodeTypes: INodeTypeDescription[]): BuilderTool {
const builderToolBase = getAddNodeToolBase(nodeTypes);
const dynamicTool = tool(
async (input, config) => {
const reporter = createProgressReporter(
config,
'add_nodes',
DISPLAY_TITLE,
builderToolBase.toolName,
builderToolBase.displayTitle,
getCustomNodeTitle(input, nodeTypes),
);
@ -181,7 +190,7 @@ export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) {
}
},
{
name: 'add_nodes',
name: builderToolBase.toolName,
description: `Add a node to the workflow canvas. Each node represents a specific action or operation (e.g., HTTP request, data transformation, database query). Always provide descriptive names that explain what the node does (e.g., "Get Customer Data", "Filter Active Users", "Send Email Notification"). The tool handles automatic positioning. Use this tool after searching for available node types to ensure they exist.
To add multiple nodes, call this tool multiple times in parallel.
@ -219,7 +228,6 @@ Think through the connectionParametersReasoning FIRST, then set connectionParame
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
getCustomDisplayTitle: (input: Record<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
...builderToolBase,
};
}

View File

@ -0,0 +1,57 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { Logger } from '@n8n/backend-common';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { createAddNodeTool, getAddNodeToolBase } from './add-node.tool';
import { CONNECT_NODES_TOOL, createConnectNodesTool } from './connect-nodes.tool';
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from './get-node-parameter.tool';
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from './node-details.tool';
import { createNodeSearchTool, NODE_SEARCH_TOOL } from './node-search.tool';
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from './remove-node.tool';
import {
createUpdateNodeParametersTool,
UPDATING_NODE_PARAMETER_TOOL,
} from './update-node-parameters.tool';
export function getBuilderTools({
parsedNodeTypes,
logger,
llmComplexTask,
instanceUrl,
}: {
parsedNodeTypes: INodeTypeDescription[];
llmComplexTask: BaseChatModel;
logger?: Logger;
instanceUrl?: string;
}): BuilderTool[] {
return [
createNodeSearchTool(parsedNodeTypes),
createNodeDetailsTool(parsedNodeTypes),
createAddNodeTool(parsedNodeTypes),
createConnectNodesTool(parsedNodeTypes, logger),
createRemoveNodeTool(logger),
createUpdateNodeParametersTool(parsedNodeTypes, llmComplexTask, logger, instanceUrl),
createGetNodeParameterTool(),
];
}
/**
* Return display information for tools
* Without the actual LangChain implementation
* Used when loading previous sessions for example
*/
export function getBuilderToolsForDisplay({
nodeTypes,
}: { nodeTypes: INodeTypeDescription[] }): BuilderToolBase[] {
return [
NODE_SEARCH_TOOL,
NODE_DETAILS_TOOL,
getAddNodeToolBase(nodeTypes),
CONNECT_NODES_TOOL,
REMOVE_NODE_TOOL,
UPDATING_NODE_PARAMETER_TOOL,
GET_NODE_PARAMETER_TOOL,
];
}

View File

@ -3,6 +3,8 @@ import type { Logger } from '@n8n/backend-common';
import { type INodeTypeDescription } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import {
ConnectionError,
NodeNotFoundError,
@ -45,16 +47,26 @@ export const nodeConnectionSchema = z.object({
.describe('The index of the input to connect to (default: 0)'),
});
export const CONNECT_NODES_TOOL: BuilderToolBase = {
toolName: 'connect_nodes',
displayTitle: 'Connecting nodes',
};
/**
* Factory function to create the connect nodes tool
*/
export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger?: Logger) {
const DISPLAY_TITLE = 'Connecting nodes';
export function createConnectNodesTool(
nodeTypes: INodeTypeDescription[],
logger?: Logger,
): BuilderTool {
const dynamicTool = tool(
// eslint-disable-next-line complexity
(input, config) => {
const reporter = createProgressReporter(config, 'connect_nodes', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
CONNECT_NODES_TOOL.toolName,
CONNECT_NODES_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
@ -290,7 +302,7 @@ export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger
}
},
{
name: 'connect_nodes',
name: CONNECT_NODES_TOOL.toolName,
description: `Connect two nodes in the workflow. The tool automatically determines the connection type based on node capabilities and ensures correct connection direction.
UNDERSTANDING CONNECTIONS:
@ -321,6 +333,6 @@ CONNECTION EXAMPLES:
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...CONNECT_NODES_TOOL,
};
}

View File

@ -10,6 +10,7 @@ import {
getCurrentWorkflow,
getWorkflowState,
} from '@/tools/helpers';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { ValidationError, ToolExecutionError } from '../errors';
import { createProgressReporter, reportProgress } from './helpers/progress';
@ -17,6 +18,8 @@ import { createSuccessResponse, createErrorResponse } from './helpers/response';
import { validateNodeExists, createNodeNotFoundError } from './helpers/validation';
import type { GetNodeParameterOutput } from '../types/tools';
const DISPLAY_TITLE = 'Getting node parameter';
/**
* Schema for getting specific node parameter
*/
@ -54,15 +57,22 @@ function formatNodeParameter(path: string, value: NodeParameterValueType): strin
return parts.join('\n');
}
export const GET_NODE_PARAMETER_TOOL: BuilderToolBase = {
toolName: 'get_node_parameter',
displayTitle: DISPLAY_TITLE,
};
/**
* Factory function to create the get node parameter tool
*/
export function createGetNodeParameterTool(logger?: Logger) {
const DISPLAY_TITLE = 'Getting node parameter';
export function createGetNodeParameterTool(logger?: Logger): BuilderTool {
const dynamicTool = tool(
(input: unknown, config) => {
const reporter = createProgressReporter(config, 'get_node_parameter', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
GET_NODE_PARAMETER_TOOL.toolName,
DISPLAY_TITLE,
);
try {
// Validate input using Zod schema
@ -134,7 +144,7 @@ export function createGetNodeParameterTool(logger?: Logger) {
const toolError = new ToolExecutionError(
error instanceof Error ? error.message : 'Unknown error occurred',
{
toolName: 'get_node_parameter',
toolName: GET_NODE_PARAMETER_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -143,7 +153,7 @@ export function createGetNodeParameterTool(logger?: Logger) {
}
},
{
name: 'get_node_parameter',
name: GET_NODE_PARAMETER_TOOL.toolName,
description:
'Get the value of a specific parameter of a specific node. Use this ONLY to retrieve parameters omitted in the workflow JSON context because of the size.',
schema: getNodeParameterSchema,
@ -152,6 +162,6 @@ export function createGetNodeParameterTool(logger?: Logger) {
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...GET_NODE_PARAMETER_TOOL,
};
}

View File

@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
import type { INodeTypeDescription } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderToolBase } from '@/utils/stream-processor';
import { ValidationError, ToolExecutionError } from '../errors';
import { createProgressReporter, reportProgress } from './helpers/progress';
import { createSuccessResponse, createErrorResponse } from './helpers/response';
@ -122,15 +124,22 @@ function extractNodeDetails(nodeType: INodeTypeDescription): NodeDetails {
};
}
export const NODE_DETAILS_TOOL: BuilderToolBase = {
toolName: 'get_node_details',
displayTitle: 'Getting node details',
};
/**
* Factory function to create the node details tool
*/
export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
const DISPLAY_TITLE = 'Getting node details';
const dynamicTool = tool(
(input: unknown, config) => {
const reporter = createProgressReporter(config, 'get_node_details', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
NODE_DETAILS_TOOL.toolName,
NODE_DETAILS_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
@ -181,7 +190,7 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
const toolError = new ToolExecutionError(
error instanceof Error ? error.message : 'Unknown error occurred',
{
toolName: 'get_node_details',
toolName: NODE_DETAILS_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -190,7 +199,7 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
}
},
{
name: 'get_node_details',
name: NODE_DETAILS_TOOL.toolName,
description:
'Get detailed information about a specific n8n node type including properties and available connections. Use this before adding nodes to understand their input/output structure.',
schema: nodeDetailsSchema,
@ -199,6 +208,6 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...NODE_DETAILS_TOOL,
};
}

View File

@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderToolBase } from '@/utils/stream-processor';
import { ValidationError, ToolExecutionError } from '../errors';
import { NodeSearchEngine } from './engines/node-search-engine';
import { createProgressReporter, createBatchProgressReporter } from './helpers/progress';
@ -108,15 +110,22 @@ function buildResponseMessage(
return responseContent;
}
export const NODE_SEARCH_TOOL: BuilderToolBase = {
toolName: 'search_nodes',
displayTitle: 'Searching nodes',
};
/**
* Factory function to create the node search tool
*/
export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
const DISPLAY_TITLE = 'Searching nodes';
const dynamicTool = tool(
(input, config) => {
const reporter = createProgressReporter(config, 'search_nodes', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
NODE_SEARCH_TOOL.toolName,
NODE_SEARCH_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
@ -178,7 +187,7 @@ export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
const toolError = new ToolExecutionError(
error instanceof Error ? error.message : 'Unknown error occurred',
{
toolName: 'search_nodes',
toolName: NODE_SEARCH_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -187,7 +196,7 @@ export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
}
},
{
name: 'search_nodes',
name: NODE_SEARCH_TOOL.toolName,
description: `Search for n8n nodes by name or find sub-nodes that output specific connection types. Use this before adding nodes to find the correct node types.
Search modes:
@ -215,6 +224,6 @@ You can search for multiple different criteria at once by providing an array of
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...NODE_SEARCH_TOOL,
};
}

View File

@ -3,6 +3,8 @@ import type { Logger } from '@n8n/backend-common';
import type { IConnections } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { ValidationError, ToolExecutionError } from '../errors';
import { createProgressReporter, reportProgress } from './helpers/progress';
import { createSuccessResponse, createErrorResponse } from './helpers/response';
@ -69,15 +71,22 @@ function buildResponseMessage(
return parts.join('\n');
}
export const REMOVE_NODE_TOOL: BuilderToolBase = {
toolName: 'remove_node',
displayTitle: 'Removing node',
};
/**
* Factory function to create the remove node tool
*/
export function createRemoveNodeTool(_logger?: Logger) {
const DISPLAY_TITLE = 'Removing node';
export function createRemoveNodeTool(_logger?: Logger): BuilderTool {
const dynamicTool = tool(
(input, config) => {
const reporter = createProgressReporter(config, 'remove_node', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
REMOVE_NODE_TOOL.toolName,
REMOVE_NODE_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
@ -139,7 +148,7 @@ export function createRemoveNodeTool(_logger?: Logger) {
const toolError = new ToolExecutionError(
error instanceof Error ? error.message : 'Unknown error occurred',
{
toolName: 'remove_node',
toolName: REMOVE_NODE_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -148,7 +157,7 @@ export function createRemoveNodeTool(_logger?: Logger) {
}
},
{
name: 'remove_node',
name: REMOVE_NODE_TOOL.toolName,
description:
'Remove a node from the workflow by its ID. This will also remove all connections to and from the node. Use this tool when you need to delete a node that is no longer needed in the workflow.',
schema: removeNodeSchema,
@ -157,6 +166,6 @@ export function createRemoveNodeTool(_logger?: Logger) {
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...REMOVE_NODE_TOOL,
};
}

View File

@ -0,0 +1,221 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { Logger } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { createNodeType, nodeTypes } from '../../../test/test-utils';
import { createAddNodeTool, getAddNodeToolBase } from '../add-node.tool';
import { getBuilderTools, getBuilderToolsForDisplay } from '../builder-tools';
import { CONNECT_NODES_TOOL, createConnectNodesTool } from '../connect-nodes.tool';
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from '../get-node-parameter.tool';
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from '../node-details.tool';
import { createNodeSearchTool, NODE_SEARCH_TOOL } from '../node-search.tool';
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from '../remove-node.tool';
import {
createUpdateNodeParametersTool,
UPDATING_NODE_PARAMETER_TOOL,
} from '../update-node-parameters.tool';
jest.mock('../add-node.tool', () => ({
createAddNodeTool: jest.fn().mockReturnValue({
name: 'addNodeTool',
tool: { name: 'addNodeTool' },
}),
getAddNodeToolBase: jest.fn().mockReturnValue({
name: 'addNodeTool',
description: 'Add a node to the workflow',
}),
}));
jest.mock('../connect-nodes.tool', () => ({
CONNECT_NODES_TOOL: {
name: 'connectNodesTool',
description: 'Connect two nodes',
},
createConnectNodesTool: jest.fn().mockReturnValue({
name: 'connectNodesTool',
tool: { name: 'connectNodesTool' },
}),
}));
jest.mock('../get-node-parameter.tool', () => ({
GET_NODE_PARAMETER_TOOL: {
name: 'getNodeParameterTool',
description: 'Get node parameters',
},
createGetNodeParameterTool: jest.fn().mockReturnValue({
name: 'getNodeParameterTool',
tool: { name: 'getNodeParameterTool' },
}),
}));
jest.mock('../node-details.tool', () => ({
NODE_DETAILS_TOOL: {
name: 'nodeDetailsTool',
description: 'Get node details',
},
createNodeDetailsTool: jest.fn().mockReturnValue({
name: 'nodeDetailsTool',
tool: { name: 'nodeDetailsTool' },
}),
}));
jest.mock('../node-search.tool', () => ({
NODE_SEARCH_TOOL: {
name: 'nodeSearchTool',
description: 'Search for nodes',
},
createNodeSearchTool: jest.fn().mockReturnValue({
name: 'nodeSearchTool',
tool: { name: 'nodeSearchTool' },
}),
}));
jest.mock('../remove-node.tool', () => ({
REMOVE_NODE_TOOL: {
name: 'removeNodeTool',
description: 'Remove a node',
},
createRemoveNodeTool: jest.fn().mockReturnValue({
name: 'removeNodeTool',
tool: { name: 'removeNodeTool' },
}),
}));
jest.mock('../update-node-parameters.tool', () => ({
UPDATING_NODE_PARAMETER_TOOL: {
name: 'updateNodeParametersTool',
description: 'Update node parameters',
},
createUpdateNodeParametersTool: jest.fn().mockReturnValue({
name: 'updateNodeParametersTool',
tool: { name: 'updateNodeParametersTool' },
}),
}));
describe('builder-tools', () => {
let mockLogger: Logger;
let mockLlmComplexTask: BaseChatModel;
let parsedNodeTypes: INodeTypeDescription[];
beforeEach(() => {
jest.clearAllMocks();
mockLogger = mock<Logger>();
mockLlmComplexTask = mock<BaseChatModel>();
parsedNodeTypes = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook];
});
describe('getBuilderTools', () => {
it('should return all builder tools in the correct order', () => {
const tools = getBuilderTools({
parsedNodeTypes,
logger: mockLogger,
llmComplexTask: mockLlmComplexTask,
instanceUrl: 'https://test.n8n.io',
});
expect(tools).toHaveLength(7);
expect(createNodeSearchTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createAddNodeTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
expect(createRemoveNodeTool).toHaveBeenCalledWith(mockLogger);
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
parsedNodeTypes,
mockLlmComplexTask,
mockLogger,
'https://test.n8n.io',
);
expect(createGetNodeParameterTool).toHaveBeenCalled();
});
it('should work without optional parameters', () => {
const tools = getBuilderTools({
parsedNodeTypes,
llmComplexTask: mockLlmComplexTask,
});
expect(tools).toHaveLength(7);
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, undefined);
expect(createRemoveNodeTool).toHaveBeenCalledWith(undefined);
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
parsedNodeTypes,
mockLlmComplexTask,
undefined,
undefined,
);
});
it('should pass through different node types', () => {
const customNodeTypes = [
createNodeType({ name: 'custom.node1' }),
createNodeType({ name: 'custom.node2' }),
];
getBuilderTools({
parsedNodeTypes: customNodeTypes,
llmComplexTask: mockLlmComplexTask,
});
expect(createNodeSearchTool).toHaveBeenCalledWith(customNodeTypes);
expect(createNodeDetailsTool).toHaveBeenCalledWith(customNodeTypes);
expect(createAddNodeTool).toHaveBeenCalledWith(customNodeTypes);
expect(createConnectNodesTool).toHaveBeenCalledWith(customNodeTypes, undefined);
});
});
describe('getBuilderToolsForDisplay', () => {
it('should return all display tools in the correct order', () => {
const tools = getBuilderToolsForDisplay({
nodeTypes: parsedNodeTypes,
});
expect(tools).toHaveLength(7);
expect(tools[0]).toBe(NODE_SEARCH_TOOL);
expect(tools[1]).toBe(NODE_DETAILS_TOOL);
expect(tools[3]).toBe(CONNECT_NODES_TOOL);
expect(tools[4]).toBe(REMOVE_NODE_TOOL);
expect(tools[5]).toBe(UPDATING_NODE_PARAMETER_TOOL);
expect(tools[6]).toBe(GET_NODE_PARAMETER_TOOL);
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
});
it('should work with empty node types array', () => {
const tools = getBuilderToolsForDisplay({
nodeTypes: [],
});
expect(tools).toHaveLength(7);
expect(getAddNodeToolBase).toHaveBeenCalledWith([]);
});
it('should work with different node types', () => {
const customNodeTypes = [
createNodeType({ name: 'custom.display.node1' }),
createNodeType({ name: 'custom.display.node2' }),
];
getBuilderToolsForDisplay({
nodeTypes: customNodeTypes,
});
expect(getAddNodeToolBase).toHaveBeenCalledWith(customNodeTypes);
});
});
describe('consistency between getBuilderTools and getBuilderToolsForDisplay', () => {
it('should return the same number of tools', () => {
const builderTools = getBuilderTools({
parsedNodeTypes,
llmComplexTask: mockLlmComplexTask,
logger: mockLogger,
});
const displayTools = getBuilderToolsForDisplay({
nodeTypes: parsedNodeTypes,
});
expect(builderTools).toHaveLength(displayTools.length);
});
});
});

View File

@ -3,6 +3,7 @@ import { tool } from '@langchain/core/tools';
import type { INode, INodeTypeDescription, INodeParameters, Logger } from 'n8n-workflow';
import { z } from 'zod';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { trimWorkflowJSON } from '@/utils/trim-workflow-context';
import { createParameterUpdaterChain } from '../chains/parameter-updater';
@ -24,8 +25,6 @@ import {
fixExpressionPrefixes,
} from './utils/parameter-update.utils';
const DISPLAY_TITLE = 'Updating node parameters';
/**
* Schema for update node parameters input
*/
@ -115,6 +114,11 @@ async function processParameterUpdates(
return fixExpressionPrefixes(newParameters.parameters) as INodeParameters;
}
export const UPDATING_NODE_PARAMETER_TOOL: BuilderToolBase = {
toolName: 'update_node_parameters',
displayTitle: 'Updating node parameters',
};
function getCustomNodeTitle(input: Record<string, unknown>, nodes?: INode[]): string {
if ('nodeId' in input && typeof input['nodeId'] === 'string') {
const targetNode = nodes?.find((node) => node.id === input.nodeId);
@ -123,7 +127,7 @@ function getCustomNodeTitle(input: Record<string, unknown>, nodes?: INode[]): st
}
}
return DISPLAY_TITLE;
return UPDATING_NODE_PARAMETER_TOOL.displayTitle;
}
/**
@ -134,10 +138,14 @@ export function createUpdateNodeParametersTool(
llm: BaseChatModel,
logger?: Logger,
instanceUrl?: string,
) {
): BuilderTool {
const dynamicTool = tool(
async (input, config) => {
const reporter = createProgressReporter(config, 'update_node_parameters', DISPLAY_TITLE);
const reporter = createProgressReporter(
config,
UPDATING_NODE_PARAMETER_TOOL.toolName,
UPDATING_NODE_PARAMETER_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
@ -215,7 +223,7 @@ export function createUpdateNodeParametersTool(
const toolError = new ToolExecutionError(
`Failed to update node parameters: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
toolName: 'update_node_parameters',
toolName: UPDATING_NODE_PARAMETER_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -235,7 +243,7 @@ export function createUpdateNodeParametersTool(
const toolError = new ToolExecutionError(
error instanceof Error ? error.message : 'Unknown error occurred',
{
toolName: 'update_node_parameters',
toolName: UPDATING_NODE_PARAMETER_TOOL.toolName,
cause: error instanceof Error ? error : undefined,
},
);
@ -244,7 +252,7 @@ export function createUpdateNodeParametersTool(
}
},
{
name: 'update_node_parameters',
name: UPDATING_NODE_PARAMETER_TOOL.toolName,
description:
'Update the parameters of an existing node in the workflow based on natural language changes. This tool intelligently modifies only the specified parameters while preserving others. Examples: "Set the URL to https://api.example.com", "Add authentication header", "Change method to POST", "Set the condition to check if status equals success".',
schema: updateNodeParametersSchema,
@ -253,6 +261,6 @@ export function createUpdateNodeParametersTool(
return {
tool: dynamicTool,
displayTitle: DISPLAY_TITLE,
...UPDATING_NODE_PARAMETER_TOOL,
};
}

View File

@ -0,0 +1,55 @@
import type { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
export interface Session {
sessionId: string;
messages: Array<Record<string, unknown>>;
lastUpdated?: string;
}
export type LangchainMessage = AIMessage | HumanMessage | ToolMessage;
/**
* Type guard to validate if a value is a valid Langchain message
*/
function isLangchainMessage(value: unknown): value is LangchainMessage {
if (!value || typeof value !== 'object') {
return false;
}
// Check for required properties that all message types have
if (!('content' in value)) {
return false;
}
const content = value.content;
if (typeof content !== 'string' && !Array.isArray(content)) {
return false;
}
// Check for message type indicators
const hasValidType =
'_getType' in value || // Common method in Langchain messages
('constructor' in value &&
value.constructor !== null &&
typeof value.constructor === 'function' &&
'name' in value.constructor &&
(value.constructor.name === 'AIMessage' ||
value.constructor.name === 'HumanMessage' ||
value.constructor.name === 'ToolMessage')) ||
('role' in value &&
typeof value.role === 'string' &&
['assistant', 'human', 'user', 'tool'].includes(value.role));
return hasValidType;
}
/**
* Type guard to validate if a value is an array of Langchain messages
*/
export function isLangchainMessagesArray(value: unknown): value is LangchainMessage[] {
if (!Array.isArray(value)) {
return false;
}
return value.every(isLangchainMessage);
}

View File

@ -9,12 +9,16 @@ import type {
StreamOutput,
} from '../types/streaming';
export interface BuilderTool {
tool: DynamicStructuredTool;
export interface BuilderToolBase {
toolName: string;
displayTitle: string;
getCustomDisplayTitle?: (values: Record<string, unknown>) => string;
}
export interface BuilderTool extends BuilderToolBase {
tool: DynamicStructuredTool;
}
/**
* Tools which should trigger canvas updates
*/
@ -195,7 +199,7 @@ function processAIMessageContent(msg: AIMessage): Array<Record<string, unknown>>
*/
function createToolCallMessage(
toolCall: ToolCall,
builderTool?: BuilderTool,
builderTool?: BuilderToolBase,
): Record<string, unknown> {
return {
id: toolCall.id,
@ -220,10 +224,10 @@ function createToolCallMessage(
*/
function processToolCalls(
toolCalls: ToolCall[],
builderTools?: BuilderTool[],
builderTools?: BuilderToolBase[],
): Array<Record<string, unknown>> {
return toolCalls.map((toolCall) => {
const builderTool = builderTools?.find((bt) => bt.tool.name === toolCall.name);
const builderTool = builderTools?.find((bt) => bt.toolName === toolCall.name);
return createToolCallMessage(toolCall, builderTool);
});
}
@ -254,7 +258,7 @@ function processToolMessage(
export function formatMessages(
messages: Array<AIMessage | HumanMessage | ToolMessage>,
builderTools?: BuilderTool[],
builderTools?: BuilderToolBase[],
): Array<Record<string, unknown>> {
const formattedMessages: Array<Record<string, unknown>> = [];

View File

@ -1,5 +1,4 @@
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
import type { DynamicStructuredTool } from '@langchain/core/tools';
import type {
AgentMessageChunk,
@ -7,6 +6,7 @@ import type {
WorkflowUpdateChunk,
StreamOutput,
} from '../../types/streaming';
import type { BuilderToolBase } from '../stream-processor';
import { processStreamChunk, createStreamProcessor, formatMessages } from '../stream-processor';
describe('stream-processor', () => {
@ -557,13 +557,13 @@ describe('stream-processor', () => {
});
it('should use builder tool display titles', () => {
const builderTools = [
const builderTools: BuilderToolBase[] = [
{
tool: { name: 'add_nodes' } as DynamicStructuredTool,
toolName: 'add_nodes',
displayTitle: 'Add Node',
},
{
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
toolName: 'connect_nodes',
displayTitle: 'Connect Nodes',
},
];
@ -601,9 +601,9 @@ describe('stream-processor', () => {
});
it('should use custom display titles from builder tools', () => {
const builderTools = [
const builderTools: BuilderToolBase[] = [
{
tool: { name: 'add_nodes' } as DynamicStructuredTool,
toolName: 'add_nodes',
displayTitle: 'Add Node',
getCustomDisplayTitle: (values: Record<string, unknown>) =>
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
@ -645,9 +645,9 @@ describe('stream-processor', () => {
});
it('should handle custom display title when args is null/undefined', () => {
const builderTools = [
const builderTools: BuilderToolBase[] = [
{
tool: { name: 'clear_workflow' } as DynamicStructuredTool,
toolName: 'clear_workflow',
displayTitle: 'Clear Workflow',
getCustomDisplayTitle: (values: Record<string, unknown>) =>
`Custom: ${Object.keys(values).length} args`,
@ -824,15 +824,15 @@ describe('stream-processor', () => {
});
it('should handle complex scenario with multiple message types and builder tools', () => {
const builderTools = [
const builderTools: BuilderToolBase[] = [
{
tool: { name: 'add_nodes' } as DynamicStructuredTool,
toolName: 'add_nodes',
displayTitle: 'Add Node',
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
getCustomDisplayTitle: (values: Record<string, unknown>) => `Add ${values.nodeType} Node`,
},
{
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
toolName: 'connect_nodes',
displayTitle: 'Connect Nodes',
},
];

View File

@ -3,7 +3,8 @@ import type { ToolMessage } from '@langchain/core/messages';
import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages';
import type { RunnableConfig } from '@langchain/core/runnables';
import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
import { StateGraph, MemorySaver, END, GraphRecursionError } from '@langchain/langgraph';
import type { MemorySaver } from '@langchain/langgraph';
import { StateGraph, END, GraphRecursionError } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
import {
ApplicationError,
@ -18,22 +19,17 @@ import {
MAX_AI_BUILDER_PROMPT_LENGTH,
MAX_INPUT_TOKENS,
} from '@/constants';
import { createGetNodeParameterTool } from '@/tools/get-node-parameter.tool';
import { trimWorkflowJSON } from '@/utils/trim-workflow-context';
import { conversationCompactChain } from './chains/conversation-compact';
import { workflowNameChain } from './chains/workflow-name';
import { LLMServiceError, ValidationError, WorkflowStateError } from './errors';
import { createAddNodeTool } from './tools/add-node.tool';
import { createConnectNodesTool } from './tools/connect-nodes.tool';
import { createNodeDetailsTool } from './tools/node-details.tool';
import { createNodeSearchTool } from './tools/node-search.tool';
import { SessionManagerService } from './session-manager.service';
import { getBuilderTools } from './tools/builder-tools';
import { mainAgentPrompt } from './tools/prompts/main-agent.prompt';
import { createRemoveNodeTool } from './tools/remove-node.tool';
import { createUpdateNodeParametersTool } from './tools/update-node-parameters.tool';
import type { SimpleWorkflow } from './types/workflow';
import { processOperations } from './utils/operations-processor';
import { createStreamProcessor, formatMessages, type BuilderTool } from './utils/stream-processor';
import { createStreamProcessor, type BuilderTool } from './utils/stream-processor';
import { estimateTokenCountFromMessages, extractLastTokenUsage } from './utils/token-usage';
import { executeToolsInParallel } from './utils/tool-executor';
import { WorkflowState } from './workflow-state';
@ -43,10 +39,11 @@ export interface WorkflowBuilderAgentConfig {
llmSimpleTask: BaseChatModel;
llmComplexTask: BaseChatModel;
logger?: Logger;
checkpointer?: MemorySaver;
checkpointer: MemorySaver;
tracer?: LangChainTracer;
autoCompactThresholdTokens?: number;
instanceUrl?: string;
onGenerationSuccess?: () => Promise<void>;
}
export interface ChatPayload {
@ -56,6 +53,12 @@ export interface ChatPayload {
currentWorkflow?: Partial<IWorkflowBase>;
executionData?: IRunExecutionData['resultData'];
};
/**
* Calls AI Assistant Service using deprecated credentials and endpoints
* These credentials/endpoints will soon be removed
* As new implementation is rolled out and builder experiment is released
*/
useDeprecatedCredentials?: boolean;
}
export class WorkflowBuilderAgent {
@ -67,34 +70,28 @@ export class WorkflowBuilderAgent {
private tracer?: LangChainTracer;
private autoCompactThresholdTokens: number;
private instanceUrl?: string;
private onGenerationSuccess?: () => Promise<void>;
constructor(config: WorkflowBuilderAgentConfig) {
this.parsedNodeTypes = config.parsedNodeTypes;
this.llmSimpleTask = config.llmSimpleTask;
this.llmComplexTask = config.llmComplexTask;
this.logger = config.logger;
this.checkpointer = config.checkpointer ?? new MemorySaver();
this.checkpointer = config.checkpointer;
this.tracer = config.tracer;
this.autoCompactThresholdTokens =
config.autoCompactThresholdTokens ?? DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS;
this.instanceUrl = config.instanceUrl;
this.onGenerationSuccess = config.onGenerationSuccess;
}
private getBuilderTools(): BuilderTool[] {
return [
createNodeSearchTool(this.parsedNodeTypes),
createNodeDetailsTool(this.parsedNodeTypes),
createAddNodeTool(this.parsedNodeTypes),
createConnectNodesTool(this.parsedNodeTypes, this.logger),
createRemoveNodeTool(this.logger),
createUpdateNodeParametersTool(
this.parsedNodeTypes,
this.llmComplexTask,
this.logger,
this.instanceUrl,
),
createGetNodeParameterTool(),
];
return getBuilderTools({
parsedNodeTypes: this.parsedNodeTypes,
instanceUrl: this.instanceUrl,
llmComplexTask: this.llmComplexTask,
logger: this.logger,
});
}
private createWorkflow() {
@ -187,6 +184,13 @@ export class WorkflowBuilderAgent {
if (lastMessage.tool_calls?.length) {
return 'tools';
}
// Call success callback when agent finishes without tool calls (successful generation)
if (this.onGenerationSuccess) {
void Promise.resolve(this.onGenerationSuccess()).catch((error) => {
this.logger?.warn('Failed to execute onGenerationSuccess callback', { error });
});
}
return END;
};
@ -313,12 +317,6 @@ export class WorkflowBuilderAgent {
});
}
static generateThreadId(workflowId?: string, userId?: string) {
return workflowId
? `workflow-${workflowId}-user-${userId ?? new Date().getTime()}`
: crypto.randomUUID();
}
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
@ -363,7 +361,7 @@ export class WorkflowBuilderAgent {
const workflowId = payload.workflowContext?.currentWorkflow?.id;
// Generate thread ID from workflowId and userId
// This ensures one session per workflow per user
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
const threadConfig: RunnableConfig = {
configurable: {
thread_id: threadId,
@ -482,43 +480,4 @@ export class WorkflowBuilderAgent {
return undefined;
}
async getSessions(workflowId: string | undefined, userId?: string) {
// For now, we'll return the current session if we have a workflowId
// MemorySaver doesn't expose a way to list all threads, so we'll need to
// track this differently if we want to list all sessions
const sessions = [];
if (workflowId) {
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
const threadConfig: RunnableConfig = {
configurable: {
thread_id: threadId,
},
};
try {
// Try to get the checkpoint for this thread
const checkpoint = await this.checkpointer.getTuple(threadConfig);
if (checkpoint?.checkpoint) {
const messages =
(checkpoint.checkpoint.channel_values?.messages as Array<
AIMessage | HumanMessage | ToolMessage
>) ?? [];
sessions.push({
sessionId: threadId,
messages: formatMessages(messages, this.getBuilderTools()),
lastUpdated: checkpoint.checkpoint.ts,
});
}
} catch (error) {
// Thread doesn't exist yet
this.logger?.debug('No session found for workflow:', { workflowId, error });
}
}
return { sessions };
}
}

View File

@ -39,5 +39,6 @@ export class AiBuilderChatRequestDto extends Z.class({
})
.optional(),
}),
useDeprecatedCredentials: z.boolean().default(false),
}),
}) {}

View File

@ -0,0 +1,7 @@
export type BuilderCreditsPushMessage = {
type: 'updateBuilderCredits';
data: {
creditsQuota: number;
creditsClaimed: number;
};
};

View File

@ -1,3 +1,4 @@
import type { BuilderCreditsPushMessage } from './builder-credits';
import type { CollaborationPushMessage } from './collaboration';
import type { DebugPushMessage } from './debug';
import type { ExecutionPushMessage } from './execution';
@ -13,7 +14,8 @@ export type PushMessage =
| WebhookPushMessage
| WorkerPushMessage
| CollaborationPushMessage
| DebugPushMessage;
| DebugPushMessage
| BuilderCreditsPushMessage;
export type PushType = PushMessage['type'];

View File

@ -37,6 +37,7 @@ export const LICENSE_FEATURES = {
API_KEY_SCOPES: 'feat:apiKeyScopes',
WORKFLOW_DIFFS: 'feat:workflowDiffs',
CUSTOM_ROLES: 'feat:customRoles',
AI_BUILDER: 'feat:aiBuilder',
} as const;
export const LICENSE_QUOTAS = {

View File

@ -122,6 +122,7 @@ describe('AiController', () => {
workflowContext: {
currentWorkflow: { id: 'workflow123' },
},
useDeprecatedCredentials: false,
},
};
@ -150,6 +151,7 @@ describe('AiController', () => {
executionData: undefined,
executionSchema: undefined,
},
useDeprecatedCredentials: false,
},
request.user,
expect.any(AbortSignal),
@ -395,4 +397,30 @@ describe('AiController', () => {
});
});
});
describe('getBuilderCredits', () => {
it('should return builder instance credits successfully', async () => {
const expectedCredits: AiAssistantSDK.BuilderInstanceCreditsResponse = {
creditsQuota: 100,
creditsClaimed: 25,
};
workflowBuilderService.getBuilderInstanceCredits.mockResolvedValue(expectedCredits);
const result = await controller.getBuilderCredits(request, response);
expect(workflowBuilderService.getBuilderInstanceCredits).toHaveBeenCalledWith(request.user);
expect(result).toEqual(expectedCredits);
});
it('should throw InternalServerError if getting credits fails', async () => {
const mockError = new Error('Failed to get credits');
workflowBuilderService.getBuilderInstanceCredits.mockRejectedValue(mockError);
await expect(controller.getBuilderCredits(request, response)).rejects.toThrow(
InternalServerError,
);
expect(workflowBuilderService.getBuilderInstanceCredits).toHaveBeenCalledWith(request.user);
});
});
});

View File

@ -8,7 +8,7 @@ import {
AiSessionRetrievalRequestDto,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Body, Post, RestController } from '@n8n/decorators';
import { Body, Get, Licensed, Post, RestController } from '@n8n/decorators';
import { type AiAssistantSDK, APIResponseError } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
@ -39,6 +39,7 @@ export class AiController {
// Use usesTemplates flag to bypass the send() wrapper which would cause
// "Cannot set headers after they are sent" error for streaming responses.
// This ensures errors during streaming are handled within the stream itself.
@Licensed('feat:aiBuilder')
@Post('/build', { rateLimit: { limit: 100 }, usesTemplates: true })
async build(
req: AuthenticatedRequest,
@ -53,7 +54,7 @@ export class AiController {
res.on('close', handleClose);
const { text, workflowContext } = payload.payload;
const { text, workflowContext, useDeprecatedCredentials } = payload.payload;
const aiResponse = this.workflowBuilderService.chat(
{
message: text,
@ -62,6 +63,7 @@ export class AiController {
executionData: workflowContext.executionData,
executionSchema: workflowContext.executionSchema,
},
useDeprecatedCredentials,
},
req.user,
signal,
@ -207,6 +209,7 @@ export class AiController {
}
}
@Licensed('feat:aiBuilder')
@Post('/sessions', { rateLimit: { limit: 100 } })
async getSessions(
req: AuthenticatedRequest,
@ -221,4 +224,18 @@ export class AiController {
throw new InternalServerError(e.message, e);
}
}
@Licensed('feat:aiBuilder')
@Get('/build/credits')
async getBuilderCredits(
req: AuthenticatedRequest,
_: Response,
): Promise<AiAssistantSDK.BuilderInstanceCreditsResponse> {
try {
return await this.workflowBuilderService.getBuilderInstanceCredits(req.user);
} catch (e) {
assert(e instanceof Error);
throw new InternalServerError(e.message, e);
}
}
}

View File

@ -116,6 +116,7 @@ export class E2EController {
[LICENSE_FEATURES.MFA_ENFORCEMENT]: false,
[LICENSE_FEATURES.WORKFLOW_DIFFS]: false,
[LICENSE_FEATURES.CUSTOM_ROLES]: false,
[LICENSE_FEATURES.AI_BUILDER]: false,
};
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {

View File

@ -0,0 +1,372 @@
import { AiWorkflowBuilderService } from '@n8n/ai-workflow-builder';
import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { mock } from 'jest-mock-extended';
import type { IUser } from 'n8n-workflow';
import type { License } from '@/license';
import type { NodeTypes } from '@/node-types';
import type { Push } from '@/push';
import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
import type { UrlService } from '@/services/url.service';
jest.mock('@n8n/ai-workflow-builder');
jest.mock('@n8n_io/ai-assistant-sdk');
const MockedAiWorkflowBuilderService = AiWorkflowBuilderService as jest.MockedClass<
typeof AiWorkflowBuilderService
>;
const MockedAiAssistantClient = AiAssistantClient as jest.MockedClass<typeof AiAssistantClient>;
describe('WorkflowBuilderService', () => {
let service: WorkflowBuilderService;
let mockNodeTypes: NodeTypes;
let mockLicense: License;
let mockConfig: GlobalConfig;
let mockLogger: Logger;
let mockUrlService: UrlService;
let mockPush: Push;
let mockUser: IUser;
beforeEach(() => {
jest.clearAllMocks();
mockNodeTypes = mock<NodeTypes>();
mockLicense = mock<License>();
mockConfig = mock<GlobalConfig>();
mockLogger = mock<Logger>();
mockUrlService = mock<UrlService>();
mockPush = mock<Push>();
mockUser = mock<IUser>();
mockUser.id = 'test-user-id';
// Setup default mocks
(mockUrlService.getInstanceBaseUrl as jest.Mock).mockReturnValue('https://instance.test.com');
(mockLicense.loadCertStr as jest.Mock).mockResolvedValue('test-cert');
(mockLicense.getConsumerId as jest.Mock).mockReturnValue('test-consumer-id');
mockConfig.aiAssistant = { baseUrl: '' };
// Reset the mocked AiWorkflowBuilderService
MockedAiWorkflowBuilderService.mockClear();
MockedAiAssistantClient.mockClear();
service = new WorkflowBuilderService(
mockNodeTypes,
mockLicense,
mockConfig,
mockLogger,
mockUrlService,
mockPush,
);
});
describe('constructor', () => {
it('should initialize without creating the service immediately', () => {
expect(MockedAiWorkflowBuilderService).not.toHaveBeenCalled();
});
});
describe('chat', () => {
it('should create AiWorkflowBuilderService on first chat call without AI assistant client', async () => {
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const mockChatGenerator = (async function* () {
yield { messages: ['response'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const generator = service.chat(mockPayload, mockUser);
const result = await generator.next();
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledWith(
mockNodeTypes,
undefined, // No client when baseUrl is not set
mockLogger,
'https://instance.test.com',
expect.any(Function), // onCreditsUpdated callback
);
expect(result.value).toEqual({ messages: ['response'] });
});
it('should create AiAssistantClient when baseUrl is configured', async () => {
mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com';
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const mockChatGenerator = (async function* () {
yield { messages: ['response'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const generator = service.chat(mockPayload, mockUser);
await generator.next();
expect(MockedAiAssistantClient).toHaveBeenCalledWith({
licenseCert: 'test-cert',
consumerId: 'test-consumer-id',
baseUrl: 'https://ai-assistant.test.com',
n8nVersion: expect.any(String),
});
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledWith(
mockNodeTypes,
expect.any(AiAssistantClient),
mockLogger,
'https://instance.test.com',
expect.any(Function),
);
});
it('should reuse the same service instance on subsequent calls', async () => {
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const mockChatGenerator1 = (async function* () {
yield { messages: ['response1'] };
})();
const mockChatGenerator2 = (async function* () {
yield { messages: ['response2'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock)
.mockReturnValueOnce(mockChatGenerator1)
.mockReturnValueOnce(mockChatGenerator2);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
// First call
const generator1 = service.chat(mockPayload, mockUser);
await generator1.next();
// Second call
const generator2 = service.chat(mockPayload, mockUser);
await generator2.next();
// Service should only be created once
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
});
it('should pass abort signal to underlying service', async () => {
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const abortController = new AbortController();
const mockChatGenerator = (async function* () {
yield { messages: ['response'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const generator = service.chat(mockPayload, mockUser, abortController.signal);
await generator.next();
expect(mockAiService.chat).toHaveBeenCalledWith(
mockPayload,
mockUser,
abortController.signal,
);
});
});
describe('getSessions', () => {
it('should create service and delegate to getSessions', async () => {
const mockSessions = {
sessions: [
{
sessionId: 'test-session',
messages: [],
lastUpdated: new Date(),
},
],
};
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.getSessions as jest.Mock).mockResolvedValue(mockSessions);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const result = await service.getSessions('workflow-123', mockUser);
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
expect(mockAiService.getSessions).toHaveBeenCalledWith('workflow-123', mockUser);
expect(result).toEqual(mockSessions);
});
it('should handle undefined workflowId', async () => {
const mockSessions = { sessions: [] };
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.getSessions as jest.Mock).mockResolvedValue(mockSessions);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const result = await service.getSessions(undefined, mockUser);
expect(mockAiService.getSessions).toHaveBeenCalledWith(undefined, mockUser);
expect(result).toEqual(mockSessions);
});
});
describe('onCreditsUpdated callback', () => {
it('should send push notification when credits are updated', async () => {
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const mockChatGenerator = (async function* () {
yield { messages: ['response'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
let capturedCallback:
| ((userId: string, creditsQuota: number, creditsClaimed: number) => void)
| undefined;
MockedAiWorkflowBuilderService.mockImplementation(
(_nodeTypes, _client, _logger, _instanceUrl, callback) => {
capturedCallback = callback;
return mockAiService;
},
);
// Trigger service creation
const generator = service.chat(mockPayload, mockUser);
await generator.next();
// Verify callback was provided
expect(capturedCallback).toBeDefined();
// Simulate credits update
capturedCallback!('user-123', 100, 5);
// Verify push notification was sent
expect(mockPush.sendToUsers).toHaveBeenCalledWith(
{
type: 'updateBuilderCredits',
data: {
creditsQuota: 100,
creditsClaimed: 5,
},
},
['user-123'],
);
});
it('should handle multiple credit updates', async () => {
const mockPayload = {
message: 'test message',
workflowContext: {},
};
const mockChatGenerator = (async function* () {
yield { messages: ['response'] };
})();
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
let capturedCallback:
| ((userId: string, creditsQuota: number, creditsClaimed: number) => void)
| undefined;
MockedAiWorkflowBuilderService.mockImplementation(
(_nodeTypes, _client, _logger, _instanceUrl, callback) => {
capturedCallback = callback;
return mockAiService;
},
);
const generator = service.chat(mockPayload, mockUser);
await generator.next();
// Simulate multiple credit updates
capturedCallback!('user-123', 100, 5);
capturedCallback!('user-456', 50, 2);
// Verify both notifications were sent
expect(mockPush.sendToUsers).toHaveBeenCalledTimes(2);
expect(mockPush.sendToUsers).toHaveBeenNthCalledWith(
1,
{
type: 'updateBuilderCredits',
data: {
creditsQuota: 100,
creditsClaimed: 5,
},
},
['user-123'],
);
expect(mockPush.sendToUsers).toHaveBeenNthCalledWith(
2,
{
type: 'updateBuilderCredits',
data: {
creditsQuota: 50,
creditsClaimed: 2,
},
},
['user-456'],
);
});
});
describe('getBuilderInstanceCredits', () => {
it('should return builder instance credits', async () => {
const expectedCredits = {
creditsQuota: 100,
creditsClaimed: 25,
};
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
const result = await service.getBuilderInstanceCredits(mockUser);
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
expect(mockAiService.getBuilderInstanceCredits).toHaveBeenCalledWith(mockUser);
expect(result).toEqual(expectedCredits);
});
it('should reuse existing service instance', async () => {
const expectedCredits = {
creditsQuota: 50,
creditsClaimed: 10,
};
const mockAiService = mock<AiWorkflowBuilderService>();
(mockAiService.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
// Call twice to test service reuse
await service.getBuilderInstanceCredits(mockUser);
const result = await service.getBuilderInstanceCredits(mockUser);
// Should only create the service once
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
expect(mockAiService.getBuilderInstanceCredits).toHaveBeenCalledTimes(2);
expect(result).toEqual(expectedCredits);
});
});
});

View File

@ -9,6 +9,7 @@ import type { IUser } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import { License } from '@/license';
import { NodeTypes } from '@/node-types';
import { Push } from '@/push';
import { UrlService } from '@/services/url.service';
/**
@ -25,6 +26,7 @@ export class WorkflowBuilderService {
private readonly config: GlobalConfig,
private readonly logger: Logger,
private readonly urlService: UrlService,
private readonly push: Push,
) {}
private async getService(): Promise<AiWorkflowBuilderService> {
@ -45,11 +47,26 @@ export class WorkflowBuilderService {
});
}
// Create callback that uses the push service
const onCreditsUpdated = (userId: string, creditsQuota: number, creditsClaimed: number) => {
this.push.sendToUsers(
{
type: 'updateBuilderCredits',
data: {
creditsQuota,
creditsClaimed,
},
},
[userId],
);
};
this.service = new AiWorkflowBuilderService(
this.nodeTypes,
client,
this.logger,
this.urlService.getInstanceBaseUrl(),
onCreditsUpdated,
);
}
return this.service;
@ -65,4 +82,9 @@ export class WorkflowBuilderService {
const sessions = await service.getSessions(workflowId, user);
return sessions;
}
async getBuilderInstanceCredits(user: IUser) {
const service = await this.getService();
return await service.getBuilderInstanceCredits(user);
}
}

View File

@ -0,0 +1,376 @@
import { chatWithBuilder } from './ai';
import * as apiUtils from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { ChatRequest } from '@/types/assistant.types';
import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest';
import type { MockInstance } from 'vitest';
vi.mock('@n8n/rest-api-client');
describe('API: ai', () => {
describe('chatWithBuilder', () => {
let mockContext: IRestApiContext;
let mockOnMessageUpdated: ReturnType<typeof vi.fn>;
let mockOnDone: ReturnType<typeof vi.fn>;
let mockOnError: ReturnType<typeof vi.fn>;
let streamRequestSpy: MockInstance;
beforeEach(() => {
mockContext = {
baseUrl: 'http://test-base-url',
sessionId: 'test-session',
pushRef: 'test-ref',
} as IRestApiContext;
mockOnMessageUpdated = vi.fn();
mockOnDone = vi.fn();
mockOnError = vi.fn();
streamRequestSpy = vi
.spyOn(apiUtils, 'streamRequest')
.mockImplementation(async () => await Promise.resolve());
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call streamRequest with the correct parameters', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
sessionId: 'session-123',
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
it('should pass abort signal when provided', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
};
const abortController = new AbortController();
const abortSignal = abortController.signal;
chatWithBuilder(
mockContext,
payload,
mockOnMessageUpdated,
mockOnDone,
mockOnError,
abortSignal,
);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
abortSignal,
);
});
it('should use deprecated credentials when flag is true', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
sessionId: 'session-456',
};
chatWithBuilder(
mockContext,
payload,
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
true,
);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: true,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
it('should handle InitSupportChat payload type', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'init-support-chat',
user: {
firstName: 'John',
},
question: 'How do I fix this error?',
workflowContext: {
currentWorkflow: {
id: 'workflow-123',
name: 'Test Workflow',
},
},
},
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
it('should handle EventRequestPayload type', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'event',
eventName: 'node-execution-succeeded',
},
sessionId: 'session-789',
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
it('should call callbacks correctly when streamRequest resolves', async () => {
const responseData: ChatRequest.ResponsePayload = {
sessionId: 'session-123',
messages: [
{
role: 'assistant' as const,
type: 'message' as const,
text: 'I will help you build that workflow.',
},
],
};
streamRequestSpy.mockImplementation(
async (
_ctx: unknown,
_url: unknown,
_payload: unknown,
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
_onError: unknown,
) => {
onMessageUpdated(responseData);
onDone();
return await Promise.resolve();
},
);
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
await vi.waitFor(() => {
expect(mockOnMessageUpdated).toHaveBeenCalledWith(responseData);
expect(mockOnDone).toHaveBeenCalled();
expect(mockOnError).not.toHaveBeenCalled();
});
});
it('should call onError when streamRequest rejects', async () => {
const error = new Error('Stream request failed');
streamRequestSpy.mockImplementation(
async (
_ctx: unknown,
_url: unknown,
_payload: unknown,
_onMessageUpdated: unknown,
_onDone: unknown,
onError: (e: Error) => void,
) => {
onError(error);
return await Promise.resolve();
},
);
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
await vi.waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith(error);
expect(mockOnDone).not.toHaveBeenCalled();
});
});
it('should handle complex workflow context in payload', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Improve my workflow',
workflowContext: {
currentWorkflow: {
id: 'workflow-123',
name: 'Test Workflow',
nodes: [],
connections: {},
},
executionSchema: [
{
nodeName: 'HTTP Request',
schema: {
type: 'object',
value: [],
path: 'data',
key: 'data',
},
},
],
},
},
sessionId: 'session-complex',
};
chatWithBuilder(
mockContext,
payload,
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
false,
);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
it('should handle undefined parameters correctly', () => {
const payload: ChatRequest.RequestPayload = {
payload: {
role: 'user',
type: 'message',
text: 'Build me a workflow',
},
};
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
expect(streamRequestSpy).toHaveBeenCalledWith(
mockContext,
'/ai/build',
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials: false,
},
},
mockOnMessageUpdated,
mockOnDone,
mockOnError,
undefined,
undefined,
);
});
});
});

View File

@ -14,11 +14,18 @@ export function chatWithBuilder(
onDone: () => void,
onError: (e: Error) => void,
abortSignal?: AbortSignal,
useDeprecatedCredentials = false,
): void {
void streamRequest<ChatRequest.ResponsePayload>(
ctx,
'/ai/build',
payload,
{
...payload,
payload: {
...payload.payload,
useDeprecatedCredentials,
},
},
onMessageUpdated,
onDone,
onError,

View File

@ -783,7 +783,13 @@ export const NDV_UI_OVERHAUL_EXPERIMENT = {
variant: 'variant',
};
export const WORKFLOW_BUILDER_EXPERIMENT = {
export const WORKFLOW_BUILDER_RELEASE_EXPERIMENT = {
name: '043_workflow_builder_release',
control: 'control',
variant: 'variant',
};
export const WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT = {
name: '036_workflow_builder_agent',
control: 'control',
variant: 'variant',
@ -830,7 +836,8 @@ export const READY_TO_RUN_V2_EXPERIMENT = {
};
export const EXPERIMENTS_TO_TRACK = [
WORKFLOW_BUILDER_EXPERIMENT.name,
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name,
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.name,

View File

@ -9,7 +9,11 @@ import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '../__tests__/defaults';
import merge from 'lodash/merge';
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.store.test';
import { WORKFLOW_BUILDER_EXPERIMENT, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import {
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
DEFAULT_NEW_WORKFLOW_NAME,
} from '@/constants';
import { reactive } from 'vue';
import * as chatAPI from '@/api/ai';
import * as telemetryModule from '@/composables/useTelemetry';
@ -416,17 +420,51 @@ describe('AI Builder store', () => {
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(true);
});
// Split into two separate tests to avoid caching issues with computed properties
it('should return true when experiment flag is set to variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.variant);
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
describe('isAIBuilderEnabled computed property', () => {
it('should return true when release experiment is set to variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control; // deprecated should be control
});
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
it('should return false when experiment flag is set to control', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.control);
expect(builderStore.isAIBuilderEnabled).toBe(false);
it('should return true when release experiment is control but deprecated experiment is variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
});
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
it('should return false when both experiments are set to control', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
expect(builderStore.isAIBuilderEnabled).toBe(false);
});
it('should prioritize release experiment over deprecated experiment', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
// Even if deprecated is control, release variant should win
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
});
it('should initialize builder chat session with prompt', async () => {
@ -704,7 +742,7 @@ describe('AI Builder store', () => {
// Verify the API was called with correct parameters
expect(apiSpy).toHaveBeenCalled();
const callArgs = apiSpy.mock.calls[0];
expect(callArgs).toHaveLength(6); // Should have 6 arguments
expect(callArgs).toHaveLength(7); // Should have 7 arguments
const signal = callArgs[5]; // The 6th argument is the abort signal
expect(signal).toBeDefined();
@ -1044,4 +1082,90 @@ describe('AI Builder store', () => {
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
});
});
describe('useDeprecatedCredentials logic in sendChatMessage', () => {
it('should set useDeprecatedCredentials to true when release experiment is control and deprecated experiment is variant', () => {
const builderStore = useBuilderStore();
// Mock posthog to return control for release and variant for deprecated
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
});
// Mock the API to capture the arguments
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test message' });
// Verify chatWithBuilder was called with useDeprecatedCredentials = true
expect(apiSpy).toHaveBeenCalledWith(
expect.anything(), // rootStore.restApiContext
expect.anything(), // payload
expect.anything(), // onMessage callback
expect.anything(), // onDone callback
expect.anything(), // onError callback
expect.anything(), // abort signal
true, // useDeprecatedCredentials
);
});
it('should set useDeprecatedCredentials to false when release experiment is variant', () => {
const builderStore = useBuilderStore();
// Mock posthog to return variant for release (regardless of deprecated)
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
});
// Mock the API to capture the arguments
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test message' });
// Verify chatWithBuilder was called with useDeprecatedCredentials = false
expect(apiSpy).toHaveBeenCalledWith(
expect.anything(), // rootStore.restApiContext
expect.anything(), // payload
expect.anything(), // onMessage callback
expect.anything(), // onDone callback
expect.anything(), // onError callback
expect.anything(), // abort signal
false, // useDeprecatedCredentials
);
});
it('should set useDeprecatedCredentials to false when both experiments are control', () => {
const builderStore = useBuilderStore();
// Mock posthog to return control for both experiments
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
}
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
});
// Mock the API to capture the arguments
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test message' });
// Verify chatWithBuilder was called with useDeprecatedCredentials = false
expect(apiSpy).toHaveBeenCalledWith(
expect.anything(), // rootStore.restApiContext
expect.anything(), // payload
expect.anything(), // onMessage callback
expect.anything(), // onDone callback
expect.anything(), // onError callback
expect.anything(), // abort signal
false, // useDeprecatedCredentials
);
});
});
});

View File

@ -3,7 +3,8 @@ import {
DEFAULT_NEW_WORKFLOW_NAME,
ASK_AI_SLIDE_OUT_DURATION_MS,
EDITABLE_CANVAS_VIEWS,
WORKFLOW_BUILDER_EXPERIMENT,
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type { ChatUI } from '@n8n/design-system/types/assistant';
@ -84,9 +85,16 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
const isAIBuilderEnabled = computed(() => {
const releaseExperimentVariant = posthogStore.getVariant(
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
);
if (releaseExperimentVariant === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant) {
return true;
}
return (
posthogStore.getVariant(WORKFLOW_BUILDER_EXPERIMENT.name) ===
WORKFLOW_BUILDER_EXPERIMENT.variant
posthogStore.getVariant(WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name) ===
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant
);
});
@ -273,6 +281,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
executionData: executionResult,
nodesForSchema: Object.keys(workflowsStore.nodesByName),
});
const retry = createRetryHandler(messageId, async () => sendChatMessage(options));
// Abort previous streaming request if any
@ -280,6 +289,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
streamingAbortController.value.abort();
}
const useDeprecatedCredentials =
posthogStore.getVariant(WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) !==
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant &&
posthogStore.getVariant(WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name) ===
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
streamingAbortController.value = new AbortController();
try {
chatWithBuilder(
@ -305,6 +320,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
() => stopStreaming(),
(e) => handleServiceError(e, messageId, retry),
streamingAbortController.value?.signal,
useDeprecatedCredentials,
);
} catch (e: unknown) {
handleServiceError(e, messageId, retry);

92
pnpm-lock.yaml generated
View File

@ -22,8 +22,8 @@ catalogs:
specifier: 0.3.20-12
version: 0.3.20-12
'@n8n_io/ai-assistant-sdk':
specifier: 1.15.0
version: 1.15.0
specifier: 1.17.0
version: 1.17.0
'@sentry/node':
specifier: ^9.42.1
version: 9.42.1
@ -420,7 +420,7 @@ importers:
version: link:../di
'@n8n_io/ai-assistant-sdk':
specifier: 'catalog:'
version: 1.15.0
version: 1.17.0
langsmith:
specifier: ^0.3.45
version: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
@ -1044,7 +1044,7 @@ importers:
version: 4.3.0
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@ -1071,7 +1071,7 @@ importers:
version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)
'@langchain/community':
specifier: 'catalog:'
version: 0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)
version: 0.3.50(8ac6ecc2064042e5620199e694862b5d)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
@ -1194,7 +1194,7 @@ importers:
version: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
langchain:
specifier: 0.3.33
version: 0.3.33(f461b118585bdb288345da9017188aa6)
version: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
lodash:
specifier: 'catalog:'
version: 4.17.21
@ -1500,7 +1500,7 @@ importers:
version: 0.3.20-12(@sentry/node@9.42.1)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.15.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2))
'@n8n_io/ai-assistant-sdk':
specifier: 'catalog:'
version: 1.15.0
version: 1.17.0
'@n8n_io/license-sdk':
specifier: 2.23.0
version: 2.23.0
@ -4307,24 +4307,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@1.9.0':
resolution: {integrity: sha512-l8U2lcqsl9yKPP5WUdIrKH//C1pWyM2cSUfcTBn6GSvXmsSjBNEdGSdM4Wfne777Oe/9ONaD1Ga53U2HksHHLw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@1.9.0':
resolution: {integrity: sha512-N3enoFoIrkB6qJWyYfTiYmFdB1R/Mrij1dd1xBHqxxCKZY9GRkEswRX3F1Uqzo5T+9Iu8nAQobDqI/ygicYy/Q==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@1.9.0':
resolution: {integrity: sha512-8jAzjrrJTj510pwq4aVs7ZKkOvEy1D+nzl9DKvrPh4TOyUw5Ie+0EDwXGE2RAkCKHkGNOQBZ78WtIdsATgz5sA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@1.9.0':
resolution: {integrity: sha512-AIjwJTGfdWGMRluSQ9pDB29nzce077dfHh0/HMqzztKzgD3spyuo2R9VoaFpbR0hLHPWEH6g6OxxDO7hfkXNkQ==}
@ -4941,67 +4945,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@ -5885,8 +5901,8 @@ packages:
engines: {node: '>=18.10', pnpm: '>=9.6'}
hasBin: true
'@n8n_io/ai-assistant-sdk@1.15.0':
resolution: {integrity: sha512-M/bNnxyVGxwLGU/mzQrZOkZK4NkR9x8cUMZHfVJlv1z6YTlHX56BYH+0jSlb2c15DEwPkku9l0RFVLTTt0ExQQ==}
'@n8n_io/ai-assistant-sdk@1.17.0':
resolution: {integrity: sha512-Zwfgf9N4aK9klCVC15xHL8R5ID8h9f6OAlW6fPJRV00cmBjX2gD8ZYaX92A9iGiKpmW5YG3mxPU7XTFVexB7wQ==}
engines: {node: '>=20.15', pnpm: '>=8.14'}
'@n8n_io/license-sdk@2.23.0':
@ -5925,30 +5941,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.70':
resolution: {integrity: sha512-wBTOllEYNfJCHOdZj9v8gLzZ4oY3oyPX8MSRvaxPm/s7RfEXxCyZ8OhJ5xAyicsDdbE5YBZqdmaaeP5+xKxvtg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.70':
resolution: {integrity: sha512-GVUUPC8TuuFqHip0rxHkUqArQnlzmlXmTEBuXAWdgCv85zTCFH8nOHk/YCF5yo0Z2eOm8nOi90aWs0leJ4OE5Q==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.70':
resolution: {integrity: sha512-/kvUa2lZRwGNyfznSn5t1ShWJnr/m5acSlhTV3eXECafObjl0VBuA1HJw0QrilLpb4Fe0VLywkpD1NsMoVDROQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.70':
resolution: {integrity: sha512-aqlv8MLpycoMKRmds7JWCfVwNf1fiZxaU7JwJs9/ExjTD8lX2KjsO7CTeAj5Cl4aEuzxUWbJPUUE2Qu9cZ1vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-x64-msvc@0.1.70':
resolution: {integrity: sha512-Q9QU3WIpwBTVHk4cPfBjGHGU4U0llQYRXgJtFtYqqGNEOKVN4OT6PQ+ve63xwIPODMpZ0HHyj/KLGc9CWc3EtQ==}
@ -6251,36 +6272,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@ -6477,56 +6504,67 @@ packages:
resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.49.0':
resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.49.0':
resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.49.0':
resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.49.0':
resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.49.0':
resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.49.0':
resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.49.0':
resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.49.0':
resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.49.0':
resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.49.0':
resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.49.0':
resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==}
@ -7813,41 +7851,49 @@ packages:
resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.9.2':
resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.9.2':
resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.9.2':
resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.9.2':
resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.9.2':
resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.9.2':
resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.9.2':
resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.9.2':
resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==}
@ -16355,8 +16401,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.1.0-alpha.0:
resolution: {integrity: sha512-K1guwS1Oy0gNfBdIdIn8JMkUV+S38sriR1zf5dP+KkPS7/r5nHnPZUL74meY2CYlxYBH4qSQ+k7bpHfwiRvaMg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@ -19288,7 +19334,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))':
dependencies:
form-data: 4.0.4
node-fetch: 2.7.0(encoding@0.1.13)
@ -19297,7 +19343,7 @@ snapshots:
zod: 3.25.67
optionalDependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
transitivePeerDependencies:
- encoding
@ -19864,7 +19910,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)':
'@langchain/community@0.3.50(8ac6ecc2064042e5620199e694862b5d)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(utf-8-validate@5.0.10)(zod@3.25.67)
'@ibm-cloud/watsonx-ai': 1.1.2
@ -19876,7 +19922,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.3.2
js-yaml: 4.1.0
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
openai: 5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)
uuid: 10.0.0
@ -19890,7 +19936,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.808.0
'@azure/storage-blob': 12.26.0
'@browserbasehq/sdk': 2.6.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 3.4.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@ -20326,7 +20372,7 @@ snapshots:
acorn: 8.12.1
acorn-walk: 8.3.4
'@n8n_io/ai-assistant-sdk@1.15.0': {}
'@n8n_io/ai-assistant-sdk@1.17.0': {}
'@n8n_io/license-sdk@2.23.0':
dependencies:
@ -21796,7 +21842,7 @@ snapshots:
storybook: 9.1.7(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@5.0.10)(vite@7.0.0(@types/node@20.19.11)(jiti@1.21.7)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
vue-component-type-helpers: 3.1.0-alpha.0
'@stylistic/eslint-plugin@5.0.0(eslint@9.29.0(jiti@1.21.7))':
dependencies:
@ -27023,7 +27069,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.11
'@types/tough-cookie': 4.0.5
axios: 1.12.0(debug@4.4.1)
axios: 1.12.0(debug@4.3.6)
camelcase: 6.3.0
debug: 4.4.1(supports-color@8.1.1)
dotenv: 16.6.1
@ -27033,7 +27079,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.12.0)
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1))
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@ -28291,7 +28337,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.33(f461b118585bdb288345da9017188aa6):
langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e):
dependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
'@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))
@ -31104,7 +31150,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.12.0):
retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)):
dependencies:
axios: 1.12.0(debug@4.3.6)
@ -33300,7 +33346,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.1.0-alpha.0: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@ -12,7 +12,7 @@ minimumReleaseAgeExclude:
catalog:
'@n8n/typeorm': 0.3.20-12
'@n8n_io/ai-assistant-sdk': 1.15.0
'@n8n_io/ai-assistant-sdk': 1.17.0
'@langchain/core': 0.3.68
'@langchain/openai': 0.6.7
'@langchain/anthropic': 0.3.26