Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ function createMockContext(
): InstanceAiContext {
return {
userId: 'user-1',
workflowService: {} as never,
workflowService: {
get: jest.fn().mockResolvedValue({ id: 'wf-1', name: 'Fetched Name' }),
} as unknown as InstanceAiContext['workflowService'],
executionService: {
list: jest.fn(),
getStatus: jest.fn(),
Expand Down Expand Up @@ -138,36 +140,41 @@ describe('executions tool', () => {
expect(context.executionService.run).not.toHaveBeenCalled();
});

it('should suspend for confirmation when approval is needed (default permission)', async () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const suspendFn = jest.fn();
const context = createMockContext({
permissions: {},
});
(context.workflowService.get as jest.Mock).mockResolvedValue({
id: 'wf-1',
name: 'My Workflow',
});

const tool = createExecutionsTool(context);
await tool.execute!(
{
action: 'run' as const,
workflowId: 'wf-1',
workflowName: 'My Workflow',
},
createAgentCtx({ suspend: suspendFn }) as never,
);

expect(context.workflowService.get).toHaveBeenCalledWith('wf-1');
expect(suspendFn).toHaveBeenCalled();
const suspendPayload = suspendFn.mock.calls[0][0] as Record<string, unknown>;
expect(suspendPayload).toEqual(
expect.objectContaining({
message: expect.stringContaining('My Workflow'),
message: 'Execute workflow "My Workflow" (ID: wf-1)?',
severity: 'warning',
requestId: expect.any(String),
}),
);
});

it('should use workflowId in message when workflowName is not provided', async () => {
it('should fall back to workflowId in message when lookup fails', async () => {
const suspendFn = jest.fn();
const context = createMockContext({ permissions: {} });
(context.workflowService.get as jest.Mock).mockRejectedValue(new Error('not found'));

const tool = createExecutionsTool(context);
await tool.execute!(
Expand All @@ -179,7 +186,7 @@ describe('executions tool', () => {
const suspendPayload = suspendFn.mock.calls[0][0] as Record<string, unknown>;
expect(suspendPayload).toEqual(
expect.objectContaining({
message: expect.stringContaining('wf-42'),
message: 'Execute workflow "wf-42" (ID: wf-42)?',
}),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,22 +202,43 @@ describe('workflows tool', () => {
});
});

it('should suspend for confirmation when no resumeData', async () => {
it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();

const tool = createWorkflowsTool(context, 'full');
await tool.execute!({ action: 'delete', workflowId: 'wf1', workflowName: 'My WF' }, {
await tool.execute!({ action: 'delete', workflowId: 'wf1' }, {
agent: { suspend, resumeData: undefined },
} as never);

expect(context.workflowService.get).toHaveBeenCalledWith('wf1');
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: expect.stringContaining('My WF'),
severity: 'warning',
});
});

it('should fall back to workflowId in message when lookup fails', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockRejectedValue(new Error('not found'));
const suspend = jest.fn();

const tool = createWorkflowsTool(context, 'full');
await tool.execute!({ action: 'delete', workflowId: 'wf1' }, {
agent: { suspend, resumeData: undefined },
} as never);

expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: expect.stringContaining('"wf1"'),
});
});

it('should archive when approved via resume', async () => {
const context = createMockContext();

Expand Down Expand Up @@ -278,6 +299,27 @@ describe('workflows tool', () => {
});
expect(result).toEqual({ success: true, activeVersionId: 'v2' });
});

it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();

const tool = createWorkflowsTool(context, 'full');
await tool.execute!({ action: 'publish', workflowId: 'wf1' }, {
agent: { suspend, resumeData: undefined },
} as never);

expect(context.workflowService.get).toHaveBeenCalledWith('wf1');
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: 'Publish workflow "My WF" (ID: wf1)?',
severity: 'warning',
});
});
});

describe('setup action', () => {
Expand Down Expand Up @@ -335,5 +377,26 @@ describe('workflows tool', () => {
expect(context.workflowService.unpublish).toHaveBeenCalledWith('wf1');
expect(result).toEqual({ success: true });
});

it('should suspend for confirmation using the looked-up workflow name', async () => {
const context = createMockContext();
(context.workflowService.get as jest.Mock).mockResolvedValue({
id: 'wf1',
name: 'My WF',
});
const suspend = jest.fn();

const tool = createWorkflowsTool(context, 'full');
await tool.execute!({ action: 'unpublish', workflowId: 'wf1' }, {
agent: { suspend, resumeData: undefined },
} as never);

expect(context.workflowService.get).toHaveBeenCalledWith('wf1');
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: 'Unpublish workflow "My WF" (ID: wf1)?',
severity: 'warning',
});
});
});
});
7 changes: 5 additions & 2 deletions packages/@n8n/instance-ai/src/tools/executions.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const getAction = z.object({
const runAction = z.object({
action: z.literal('run').describe('Execute a workflow and wait for completion'),
workflowId: z.string().describe('Workflow ID'),
workflowName: z.string().optional().describe('Name of the workflow (for confirmation message)'),
inputData: z
.record(z.unknown())
.optional()
Expand Down Expand Up @@ -144,9 +143,13 @@ async function handleRun(

// If approval is required and this is the first call, suspend for confirmation
if (needsApproval && (resumeData === undefined || resumeData === null)) {
const workflowName = await context.workflowService
.get(input.workflowId)
.then((wf) => wf.name)
.catch(() => input.workflowId);
await suspend?.({
requestId: nanoid(),
message: `Execute workflow "${input.workflowName ?? input.workflowId}" (ID: ${input.workflowId})?`,
message: `Execute workflow "${workflowName}" (ID: ${input.workflowId})?`,
severity: 'warning' as const,
});
return {
Expand Down
25 changes: 17 additions & 8 deletions packages/@n8n/instance-ai/src/tools/workflows.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ const getAsCodeAction = z.object({
const deleteAction = z.object({
action: z.literal('delete').describe('Archive a workflow by ID (soft delete)'),
workflowId: z.string().describe('ID of the workflow'),
workflowName: z.string().optional().describe('Name of the workflow (for confirmation message)'),
});

const setupAction = z.object({
Expand All @@ -54,7 +53,6 @@ const publishBaseAction = z.object({
.literal('publish')
.describe('Publish a workflow version to production (omit versionId for latest draft)'),
workflowId: z.string().describe('ID of the workflow'),
workflowName: z.string().optional().describe('Name of the workflow (for confirmation message)'),
versionId: z.string().optional().describe('Version ID'),
});

Expand All @@ -66,7 +64,6 @@ const publishExtendedAction = publishBaseAction.extend({
const unpublishAction = z.object({
action: z.literal('unpublish').describe('Unpublish a workflow — stop it from running'),
workflowId: z.string().describe('ID of the workflow'),
workflowName: z.string().optional().describe('Name of the workflow (for confirmation message)'),
});

const listVersionsAction = z.object({
Expand Down Expand Up @@ -161,6 +158,16 @@ function buildInputSchema(context: InstanceAiContext, surface: 'full' | 'orchest

// ── Handlers ────────────────────────────────────────────────────────────────

async function resolveWorkflowName(
context: InstanceAiContext,
workflowId: string,
): Promise<string> {
return await context.workflowService
.get(workflowId)
.then((wf) => wf.name)
.catch(() => workflowId);
}

async function handleList(context: InstanceAiContext, input: Extract<Input, { action: 'list' }>) {
const workflows = await context.workflowService.list({
limit: input.limit,
Expand Down Expand Up @@ -208,9 +215,10 @@ async function handleDelete(

// First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
const workflowName = await resolveWorkflowName(context, input.workflowId);
await suspend?.({
requestId: nanoid(),
message: `Archive workflow "${input.workflowName ?? input.workflowId}"? This will deactivate it if needed and can be undone later.`,
message: `Archive workflow "${workflowName}" (ID: ${input.workflowId})? This will deactivate it if needed and can be undone later.`,
severity: 'warning' as const,
});
// suspend() never resolves — this line is unreachable but satisfies the type checker
Expand Down Expand Up @@ -440,13 +448,13 @@ async function handlePublish(
const needsApproval = context.permissions?.publishWorkflow !== 'always_allow';

if (needsApproval && (resumeData === undefined || resumeData === null)) {
const label = input.workflowName ?? input.workflowId;
const workflowName = await resolveWorkflowName(context, input.workflowId);

await suspend?.({
requestId: nanoid(),
message: input.versionId
? `Publish version "${input.versionId}" of workflow "${label}"?`
: `Publish workflow "${label}"?`,
? `Publish version "${input.versionId}" of workflow "${workflowName}" (ID: ${input.workflowId})?`
: `Publish workflow "${workflowName}" (ID: ${input.workflowId})?`,
severity: 'warning' as const,
});
return { success: false };
Expand Down Expand Up @@ -490,9 +498,10 @@ async function handleUnpublish(
const needsApproval = context.permissions?.publishWorkflow !== 'always_allow';

if (needsApproval && (resumeData === undefined || resumeData === null)) {
const workflowName = await resolveWorkflowName(context, input.workflowId);
await suspend?.({
requestId: nanoid(),
message: `Unpublish workflow "${input.workflowName ?? input.workflowId}"?`,
message: `Unpublish workflow "${workflowName}" (ID: ${input.workflowId})?`,
severity: 'warning' as const,
});
return { success: false };
Expand Down
Loading