Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ coverage/
.temp/
tmp/
temp/
# Generated node catalog (run `bun run crawl-nodes` to regenerate)
# Generated data files (run `bun run crawl` to regenerate)
src/data/defaultNodes.json
src/data/schemaIndex.json

308 changes: 308 additions & 0 deletions __tests__/integration/actions/createWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,314 @@ describe('CREATE_N8N_WORKFLOW action', () => {
});
});

// ==========================================================================
// EXPLICIT INTENT VIA _OPTIONS (multi-step agent support)
// ==========================================================================

describe('handler - explicit intent via _options', () => {
function createDraftInCache(): WorkflowDraft {
return {
workflow: {
name: 'Stripe Gmail Summary',
nodes: [
{
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
{
name: 'Gmail',
type: 'n8n-nodes-base.gmail',
typeVersion: 2,
position: [200, 0] as [number, number],
parameters: { operation: 'send' },
credentials: {
gmailOAuth2Api: { id: '{{CREDENTIAL_ID}}', name: 'Gmail Account' },
},
},
],
connections: {
'Schedule Trigger': {
main: [[{ node: 'Gmail', type: 'main', index: 0 }]],
},
},
},
prompt: 'Send Stripe summaries via Gmail',
userId: 'user-001',
createdAt: Date.now(),
};
}

test('explicit intent=confirm bypasses LLM classification and deploys', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

// useModel returns "modify" — but should be IGNORED when explicit intent is passed
const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'modify', reason: 'LLM says modify' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'modifie le workflow pour utiliser outlook' }, // Text says modify
});
const callback = createMockCallback();

// Pass explicit intent=confirm via _options
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
{ intent: 'confirm' }, // <-- explicit intent
callback
);

expect(result?.success).toBe(true);
// Should deploy, not modify
expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1);
expect(mockService.modifyWorkflowDraft).not.toHaveBeenCalled();
expect(runtime.deleteCache).toHaveBeenCalled();
});

test('explicit intent=cancel bypasses LLM classification and cancels', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

// useModel returns "confirm" — but should be IGNORED
const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'yes deploy it' }, // Text says confirm
});
const callback = createMockCallback();

// Pass explicit intent=cancel via _options
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
{ intent: 'cancel' }, // <-- explicit intent
callback
);

expect(result?.success).toBe(true);
// Should cancel, not deploy
expect(mockService.deployWorkflow).not.toHaveBeenCalled();
expect(runtime.deleteCache).toHaveBeenCalled();

const calls = (callback as any).mock.calls;
const lastText = calls[calls.length - 1][0].text;
expect(lastText).toContain('Stripe Gmail Summary'); // cancelled workflow name
});

test('explicit intent=modify with modification bypasses LLM classification', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

// useModel returns "confirm" — but should be IGNORED
const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'ok' }, // Ambiguous text
});
const callback = createMockCallback();

// Pass explicit intent=modify with modification via _options
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
{ intent: 'modify', modification: 'Use Outlook instead of Gmail' }, // <-- explicit
callback
);

expect(result?.success).toBe(true);
expect(result?.data).toEqual({ awaitingUserInput: true });
// Should modify, not deploy
expect(mockService.modifyWorkflowDraft).toHaveBeenCalledTimes(1);
expect(mockService.deployWorkflow).not.toHaveBeenCalled();

// Should use the modification from _options, not the message text
const modifyCall = (mockService.modifyWorkflowDraft as any).mock.calls[0];
expect(modifyCall[1]).toBe('Use Outlook instead of Gmail');
});

test('explicit intent=new bypasses LLM classification and generates fresh', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

// useModel returns "confirm" — but should be IGNORED
const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'Create a Slack notification workflow' },
});
const callback = createMockCallback();

// Pass explicit intent=new via _options
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
{ intent: 'new' }, // <-- explicit intent
callback
);

expect(result?.success).toBe(true);
// Should generate new workflow, not deploy existing draft
expect(mockService.generateWorkflowDraft).toHaveBeenCalledTimes(1);
expect(mockService.deployWorkflow).not.toHaveBeenCalled();
expect(runtime.deleteCache).toHaveBeenCalled(); // Clears old draft before generating
});

test('invalid explicit intent falls back to LLM classification', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

// useModel returns "cancel"
const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'cancel', reason: 'LLM says cancel' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'cancel it' },
});
const callback = createMockCallback();

// Pass invalid intent via _options — should be ignored
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
{ intent: 'invalid_intent' }, // <-- invalid, falls back to LLM
callback
);

expect(result?.success).toBe(true);
// Should use LLM result (cancel)
expect(mockService.deployWorkflow).not.toHaveBeenCalled();
expect(runtime.deleteCache).toHaveBeenCalled();
});

test('no _options falls back to LLM classification', async () => {
const draft = createDraftInCache();
const mockService = createMockService();

const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'confirm', reason: 'User agreed' }),
cache: { 'workflow_draft:user-001': draft },
});

const message = createMockMessage({
content: { text: 'yes deploy' },
});
const callback = createMockCallback();

// No _options passed — uses LLM
const result = await createWorkflowAction.handler(
runtime,
message,
createMockState(),
undefined, // <-- no options
callback
);

expect(result?.success).toBe(true);
expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1);
});
});

// ==========================================================================
// MODIFY INCLUDES CHANGES IN PREVIEW
// ==========================================================================

describe('handler - modify includes changes in preview', () => {
test('preview data includes changed parameters after modify', async () => {
const draft: WorkflowDraft = {
workflow: {
name: 'Gmail Forward',
nodes: [
{
name: 'Gmail Trigger',
type: 'n8n-nodes-base.gmailTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: { pollTimes: { item: [{ mode: 'everyMinute' }] } },
},
{
name: 'Forward Email',
type: 'n8n-nodes-base.gmail',
typeVersion: 2,
position: [200, 0] as [number, number],
parameters: { operation: 'send', sendTo: '[email protected]' },
credentials: { gmailOAuth2Api: { id: 'cred-1', name: 'Gmail' } },
},
],
connections: {
'Gmail Trigger': { main: [[{ node: 'Forward Email', type: 'main', index: 0 }]] },
},
},
prompt: 'Forward emails',
userId: 'user-001',
createdAt: Date.now(),
};

const modifiedWorkflow = {
...draft.workflow,
nodes: [
draft.workflow.nodes[0],
{
...draft.workflow.nodes[1],
parameters: { operation: 'send', sendTo: '[email protected]' },
},
],
};

const mockService = createMockService({
modifyWorkflowDraft: mock(() => Promise.resolve(modifiedWorkflow)),
});

const runtime = createMockRuntime({
services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService },
useModel: createUseModelMock({ intent: 'modify', reason: 'User wants to modify' }),
cache: { 'workflow_draft:user-001': draft },
});

const callback = createMockCallback();

await createWorkflowAction.handler(
runtime,
createMockMessage({ content: { text: 'change email to [email protected]' } }),
createMockState(),
{ intent: 'modify', modification: 'change email to [email protected]' },
callback
);

// The callback text should contain the new email (changes are passed to formatActionResponse)
const calls = (callback as any).mock.calls;
const lastText = calls[calls.length - 1][0].text;
expect(lastText).toContain('[email protected]');
});
});

// ==========================================================================
// CALLBACK SUCCESS STATUS TESTS
// ==========================================================================
Expand Down
Loading
Loading