From 031f91888a3a7204c88bc35efd5b10a317365412 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Mon, 20 Apr 2026 18:18:48 +0300 Subject: [PATCH] feat(core): Include workflow names on instance AI confirmations --- .../tools/__tests__/executions.tool.test.ts | 19 ++++-- .../tools/__tests__/workflows.tool.test.ts | 67 ++++++++++++++++++- .../instance-ai/src/tools/executions.tool.ts | 7 +- .../instance-ai/src/tools/workflows.tool.ts | 25 ++++--- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts index ee2f1628cce0e..fd7ef278d9667 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/executions.tool.test.ts @@ -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(), @@ -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; 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!( @@ -179,7 +186,7 @@ describe('executions tool', () => { const suspendPayload = suspendFn.mock.calls[0][0] as Record; expect(suspendPayload).toEqual( expect.objectContaining({ - message: expect.stringContaining('wf-42'), + message: 'Execute workflow "wf-42" (ID: wf-42)?', }), ); }); diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 8ee39822afbc0..1e2bc3e8ec382 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -202,15 +202,20 @@ 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'), @@ -218,6 +223,22 @@ describe('workflows tool', () => { }); }); + 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(); @@ -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', () => { @@ -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', + }); + }); }); }); diff --git a/packages/@n8n/instance-ai/src/tools/executions.tool.ts b/packages/@n8n/instance-ai/src/tools/executions.tool.ts index 313b2b576cf85..51a409a9fc28b 100644 --- a/packages/@n8n/instance-ai/src/tools/executions.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/executions.tool.ts @@ -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() @@ -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 { diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index a2fc4cde9dca5..b8b52ca1769a9 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -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({ @@ -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'), }); @@ -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({ @@ -161,6 +158,16 @@ function buildInputSchema(context: InstanceAiContext, surface: 'full' | 'orchest // ── Handlers ──────────────────────────────────────────────────────────────── +async function resolveWorkflowName( + context: InstanceAiContext, + workflowId: string, +): Promise { + return await context.workflowService + .get(workflowId) + .then((wf) => wf.name) + .catch(() => workflowId); +} + async function handleList(context: InstanceAiContext, input: Extract) { const workflows = await context.workflowService.list({ limit: input.limit, @@ -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 @@ -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 }; @@ -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 };