diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts index fbfb9b09aec..034dc809531 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts @@ -409,7 +409,7 @@ describe('credentials tool', () => { expect(suspendFn.mock.calls[0][0]).toEqual( expect.objectContaining({ requestId: expect.any(String), - message: 'Delete credential "My Cred"? This cannot be undone.', + message: 'Delete My Cred', severity: 'destructive', }), ); @@ -432,7 +432,7 @@ describe('credentials tool', () => { expect(suspendFn).toHaveBeenCalledTimes(1); expect(suspendFn.mock.calls[0][0]).toEqual( expect.objectContaining({ - message: 'Delete credential "cred-99"? This cannot be undone.', + message: 'Delete cred-99', }), ); }); diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts index 1b09ded2537..3c0afe282be 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/data-tables.tool.test.ts @@ -264,7 +264,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Create data table "Contacts"?', + message: 'Create Contacts', severity: 'info', }), ); @@ -297,7 +297,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Create data table "Contacts" in project "My Project"?', + message: 'Create Contacts in project My Project', }), ); }); @@ -399,14 +399,31 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: - 'Delete data table "dt-1"? This will permanently remove the table and all its data.', + message: 'Delete dt-1', severity: 'destructive', }), ); expect(context.dataTableService.delete).not.toHaveBeenCalled(); }); + it('should include the table name in the suspend message when provided', async () => { + const context = createMockContext({ permissions: {} }); + const suspendFn = jest.fn(); + + const tool = createDataTablesTool(context); + await executeTool( + tool, + { ...deleteInput, dataTableName: 'Customer data' } as never, + suspendCtx(suspendFn), + ); + + expect(suspendFn.mock.calls[0]![0]).toEqual( + expect.objectContaining({ + message: 'Delete Customer data (ID: dt-1)', + }), + ); + }); + it('should execute immediately when permission is always_allow', async () => { const context = createMockContext({ permissions: { deleteDataTable: 'always_allow' } }); @@ -472,7 +489,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Add column "age" (number) to data table "dt-1"?', + message: 'Add age (number) to dt-1', severity: 'warning', }), ); @@ -547,8 +564,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: - 'Delete column "col-1" from data table "dt-1"? All data in this column will be permanently lost.', + message: 'Delete col-1 from dt-1', severity: 'destructive', }), ); @@ -620,7 +636,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Rename column "col-1" to "full_name" in data table "dt-1"?', + message: 'Rename col-1 to full_name in dt-1', severity: 'warning', }), ); @@ -697,7 +713,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Insert 2 row(s) into data table "dt-1"?', + message: 'Insert 2 row(s) into dt-1', severity: 'warning', }), ); @@ -798,7 +814,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Update rows in data table "dt-1"?', + message: 'Update rows in dt-1', severity: 'warning', }), ); @@ -880,7 +896,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Delete rows where status eq inactive? This cannot be undone.', + message: 'Delete rows from dt-1 where status eq inactive', severity: 'destructive', }), ); @@ -909,7 +925,7 @@ describe('data-tables tool', () => { expect(suspendFn).toHaveBeenCalled(); expect(suspendFn.mock.calls[0]![0]).toEqual( expect.objectContaining({ - message: 'Delete rows where status eq inactive or age lt 18? This cannot be undone.', + message: 'Delete rows from dt-1 where status eq inactive or age lt 18', }), ); }); 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 f41913ea3c6..348882ee3ec 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 @@ -167,7 +167,7 @@ describe('executions tool', () => { const suspendPayload = suspendFn.mock.calls[0][0] as Record; expect(suspendPayload).toEqual( expect.objectContaining({ - message: 'Execute workflow "My Workflow" (ID: wf-1)?', + message: 'Execute My Workflow (ID: wf-1)', severity: 'warning', requestId: expect.any(String), }), @@ -190,7 +190,7 @@ describe('executions tool', () => { const suspendPayload = suspendFn.mock.calls[0][0] as Record; expect(suspendPayload).toEqual( expect.objectContaining({ - message: 'Execute workflow "wf-42" (ID: wf-42)?', + message: 'Execute 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 d57819842ae..7258fc66006 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 @@ -278,7 +278,7 @@ describe('workflows tool', () => { expect(suspend).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Update workflow version "1" — set name to "v1", description to (cleared)?', + message: 'Update version 1 — set name to "v1", description to (cleared)', severity: 'info', }), ); @@ -490,7 +490,7 @@ describe('workflows tool', () => { expect(suspend).toHaveBeenCalled(); expect(suspend.mock.calls[0][0]).toMatchObject({ - message: expect.stringContaining('"wf1"'), + message: expect.stringContaining('wf1'), }); }); @@ -563,7 +563,6 @@ describe('workflows tool', () => { message: expect.stringContaining('Archived WF'), severity: 'warning', }); - expect(suspend.mock.calls[0][0].message).toContain('will not publish it'); }); it('should return the suspension result when approval is pending', async () => { @@ -768,7 +767,7 @@ describe('workflows tool', () => { expect(context.workflowService.get).toHaveBeenCalledWith('wf1'); expect(suspend).toHaveBeenCalled(); expect(suspend.mock.calls[0][0]).toMatchObject({ - message: 'Publish workflow "My WF" (ID: wf1)?', + message: 'Publish My WF (ID: wf1)', severity: 'warning', }); }); @@ -799,7 +798,7 @@ describe('workflows tool', () => { } as never); expect(suspend.mock.calls[0][0]).toMatchObject({ - message: 'Publish workflow "My WF" (ID: wf1) and 1 referenced supporting workflow(s)?', + message: 'Publish My WF (ID: wf1) and 1 referenced supporting workflow(s)', severity: 'warning', }); }); @@ -959,7 +958,7 @@ describe('workflows tool', () => { expect(context.workflowService.get).toHaveBeenCalledWith('wf1'); expect(suspend).toHaveBeenCalled(); expect(suspend.mock.calls[0][0]).toMatchObject({ - message: 'Unpublish workflow "My WF" (ID: wf1)?', + message: 'Unpublish My WF (ID: wf1)', severity: 'warning', }); }); diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index 97b2c8a3e08..e4dc19fc636 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -304,7 +304,7 @@ async function handleDelete( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Delete credential "${input.credentialName ?? input.credentialId}"? This cannot be undone.`, + message: `Delete ${input.credentialName ?? input.credentialId}`, severity: 'destructive' as const, }); } diff --git a/packages/@n8n/instance-ai/src/tools/data-tables.tool.ts b/packages/@n8n/instance-ai/src/tools/data-tables.tool.ts index 959d4ca0362..caa3b0e308c 100644 --- a/packages/@n8n/instance-ai/src/tools/data-tables.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/data-tables.tool.ts @@ -77,6 +77,16 @@ function isNameConflictError(error: unknown): boolean { const projectIdDescribe = 'Project ID. For list/create, scopes the operation to this project (defaults to personal). For id-based actions (schema, query, delete, add-column, delete-column, rename-column, insert/update/delete-rows), disambiguates when `dataTableId` is a name that exists in multiple accessible projects. Ignored when `dataTableId` is a UUID; rejected when the UUID belongs to a different project.'; +const dataTableNameDescribe = + 'Human-readable name of the data table, shown alongside the ID in the approval card. Pass this whenever you know it (e.g. from a prior `list` call) so users see a recognisable label instead of a bare UUID.'; + +/** Renders `"{name} (ID: {id})"` when the agent supplied a name, otherwise the bare id. */ +function buildDataTableLabel(input: { dataTableId: string; dataTableName?: string }): string { + return input.dataTableName + ? `${input.dataTableName} (ID: ${input.dataTableId})` + : input.dataTableId; +} + const listAction = z.object({ action: z.literal('list').describe('List data tables in a project'), projectId: z.string().optional().describe(projectIdDescribe), @@ -133,6 +143,7 @@ const deleteAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), }); @@ -143,6 +154,7 @@ const addColumnAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), columnName: z.string().describe('Column name (alphanumeric + underscores)'), type: columnTypeSchema.describe('Column data type'), @@ -155,6 +167,7 @@ const deleteColumnAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), columnId: z.string().describe('ID of the column'), }); @@ -166,6 +179,7 @@ const renameColumnAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), columnId: z.string().describe('ID of the column'), newName: z.string().describe('New column name'), @@ -178,6 +192,7 @@ const insertRowsAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), rows: z .array(z.record(z.unknown())) @@ -193,6 +208,7 @@ const updateRowsAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), filter: filterSchema.describe('Row filter conditions'), data: z.record(z.unknown()).describe('Column values to set on matching rows'), @@ -209,6 +225,7 @@ const deleteRowsAction = z.object({ .describe( 'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.', ), + dataTableName: z.string().optional().describe(dataTableNameDescribe), projectId: z.string().optional().describe(projectIdDescribe), filter: filterSchemaWithMinOne.describe('Row filter conditions'), }); @@ -291,11 +308,11 @@ async function handleCreate( // State 1: First call — suspend for confirmation (unless always_allow) if (needsApproval && (resumeData === undefined || resumeData === null)) { - let message = `Create data table "${input.name}"?`; + let message = `Create ${input.name}`; if (input.projectId) { const project = await context.workspaceService?.getProject?.(input.projectId); const projectLabel = project?.name ?? input.projectId; - message = `Create data table "${input.name}" in project "${projectLabel}"?`; + message = `Create ${input.name} in project ${projectLabel}`; } return await ctx.suspend({ requestId: nanoid(), @@ -345,7 +362,7 @@ async function handleDelete( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Delete data table "${input.dataTableId}"? This will permanently remove the table and all its data.`, + message: `Delete ${buildDataTableLabel(input)}`, severity: 'destructive' as const, }); } @@ -377,7 +394,7 @@ async function handleAddColumn( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Add column "${input.columnName}" (${input.type}) to data table "${input.dataTableId}"?`, + message: `Add ${input.columnName} (${input.type}) to ${buildDataTableLabel(input)}`, severity: 'warning' as const, }); } @@ -413,7 +430,7 @@ async function handleDeleteColumn( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Delete column "${input.columnId}" from data table "${input.dataTableId}"? All data in this column will be permanently lost.`, + message: `Delete ${input.columnId} from ${buildDataTableLabel(input)}`, severity: 'destructive' as const, }); } @@ -447,7 +464,7 @@ async function handleRenameColumn( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Rename column "${input.columnId}" to "${input.newName}" in data table "${input.dataTableId}"?`, + message: `Rename ${input.columnId} to ${input.newName} in ${buildDataTableLabel(input)}`, severity: 'warning' as const, }); } @@ -481,7 +498,7 @@ async function handleInsertRows( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Insert ${input.rows.length} row(s) into data table "${input.dataTableId}"?`, + message: `Insert ${input.rows.length} row(s) into ${buildDataTableLabel(input)}`, severity: 'warning' as const, }); } @@ -514,7 +531,7 @@ async function handleUpdateRows( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Update rows in data table "${input.dataTableId}"?`, + message: `Update rows in ${buildDataTableLabel(input)}`, severity: 'warning' as const, }); } @@ -556,7 +573,7 @@ async function handleDeleteRows( .join(` ${input.filter.type} `); return await ctx.suspend({ requestId: nanoid(), - message: `Delete rows where ${filterDesc}? This cannot be undone.`, + message: `Delete rows from ${buildDataTableLabel(input)} where ${filterDesc}`, severity: 'destructive' as const, }); } diff --git a/packages/@n8n/instance-ai/src/tools/executions.tool.ts b/packages/@n8n/instance-ai/src/tools/executions.tool.ts index f03a2095a37..f72b72d4982 100644 --- a/packages/@n8n/instance-ai/src/tools/executions.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/executions.tool.ts @@ -157,7 +157,7 @@ async function handleRun( .catch(() => input.workflowId); return await suspend({ requestId: nanoid(), - message: `Execute workflow "${workflowName}" (ID: ${input.workflowId})?`, + message: `Execute ${workflowName} (ID: ${input.workflowId})`, severity: 'warning' as const, }); } diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts index a75a302f340..a65a4fdd754 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts @@ -1135,8 +1135,7 @@ describe('createBuildWorkflowAgentTool — existing workflow approval', () => { expect(out).toBeUndefined(); expect(suspend).toHaveBeenCalledWith( expect.objectContaining({ - message: - 'Edit existing workflow "Existing Workflow" (ID: WF_EXISTING)? Reason: Swap Slack channel on this notifier.', + message: 'Edit Existing Workflow (ID: WF_EXISTING)', severity: 'warning', }), ); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts index b6877c3655a..4f56849af3c 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts @@ -1737,10 +1737,9 @@ export function createBuildWorkflowAgentTool(context: OrchestrationContext) { context, input.workflowId, ); - const reason = input.reason?.trim(); return await ctx.suspend({ requestId: nanoid(), - message: `Edit existing workflow "${workflowName}" (ID: ${input.workflowId})?${reason ? ` Reason: ${reason}` : ''}`, + message: `Edit ${workflowName} (ID: ${input.workflowId})`, severity: 'warning', }); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index dc33bf079b3..13cffb062e8 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -406,7 +406,7 @@ async function handleDelete( const workflowName = await resolveWorkflowName(context, input.workflowId); return await ctx.suspend({ requestId: nanoid(), - message: `Archive workflow "${workflowName}" (ID: ${input.workflowId})? This will deactivate it if needed and can be undone later.`, + message: `Archive ${workflowName} (ID: ${input.workflowId})`, severity: 'warning' as const, }); } @@ -437,7 +437,7 @@ async function handleUnarchive( const workflowName = await resolveWorkflowName(context, input.workflowId); return await ctx.suspend({ requestId: nanoid(), - message: `Restore archived workflow "${workflowName}" (ID: ${input.workflowId})? This will make it visible again but will not publish it.`, + message: `Restore ${workflowName} (ID: ${input.workflowId})`, severity: 'warning' as const, }); } @@ -733,8 +733,8 @@ async function handlePublish( return await ctx.suspend({ requestId: nanoid(), message: input.versionId - ? `Publish version "${input.versionId}" of workflow "${workflowName}" (ID: ${input.workflowId})${dependencyNote}?` - : `Publish workflow "${workflowName}" (ID: ${input.workflowId})${dependencyNote}?`, + ? `Publish version ${input.versionId} of ${workflowName} (ID: ${input.workflowId})${dependencyNote}` + : `Publish ${workflowName} (ID: ${input.workflowId})${dependencyNote}`, severity: 'warning' as const, }); } @@ -879,7 +879,7 @@ async function handleUnpublish( const workflowName = await resolveWorkflowName(context, input.workflowId); return await ctx.suspend({ requestId: nanoid(), - message: `Unpublish workflow "${workflowName}" (ID: ${input.workflowId})?`, + message: `Unpublish ${workflowName} (ID: ${input.workflowId})`, severity: 'warning' as const, }); } @@ -942,7 +942,7 @@ async function handleRestoreVersion( return await ctx.suspend({ requestId: nanoid(), - message: `Restore workflow to version ${versionLabel}? This will overwrite the current draft.`, + message: `Restore to version ${versionLabel}`, severity: 'warning' as const, }); } @@ -987,7 +987,7 @@ async function handleUpdateVersion( return await ctx.suspend({ requestId: nanoid(), - message: `Update workflow version "${input.versionId}" — set ${summary}?`, + message: `Update version ${input.versionId} — set ${summary}`, severity: 'info' as const, }); } diff --git a/packages/@n8n/instance-ai/src/tools/workspace.tool.ts b/packages/@n8n/instance-ai/src/tools/workspace.tool.ts index 231ffec229c..fc34af44fe7 100644 --- a/packages/@n8n/instance-ai/src/tools/workspace.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workspace.tool.ts @@ -167,7 +167,7 @@ async function handleTagWorkflow( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Tag workflow "${input.workflowName ?? input.workflowId}" (ID: ${input.workflowId}) with [${input.tags.join(', ')}]?`, + message: `Tag ${input.workflowName ?? input.workflowId} (ID: ${input.workflowId}) with [${input.tags.join(', ')}]`, severity: 'info' as const, }); } @@ -200,7 +200,7 @@ async function handleCleanupTestExecutions( const hours = input.olderThanHours ?? 1; return await ctx.suspend({ requestId: nanoid(), - message: `Delete test executions for workflow "${input.workflowName ?? input.workflowId}" older than ${hours} hour(s)?`, + message: `Delete executions for ${input.workflowName ?? input.workflowId} older than ${hours} hour(s)`, severity: 'warning' as const, }); } @@ -248,7 +248,7 @@ async function handleCreateFolder( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Create folder "${input.name}" in project "${input.projectId}"?`, + message: `Create ${input.name} in project ${input.projectId}`, severity: 'info' as const, }); } @@ -288,12 +288,9 @@ async function handleDeleteFolder( // State 1: First call — suspend for confirmation (unless always_allow) if (needsApproval && (resumeData === undefined || resumeData === null)) { - const transferNote = input.transferToFolderId - ? ` Contents will be moved to folder "${input.transferToFolderName ?? input.transferToFolderId}".` - : ' Contents will be flattened to project root and archived.'; return await ctx.suspend({ requestId: nanoid(), - message: `Delete folder "${input.folderName ?? input.folderId}"?${transferNote}`, + message: `Delete ${input.folderName ?? input.folderId}`, severity: 'destructive' as const, }); } @@ -329,7 +326,7 @@ async function handleMoveWorkflowToFolder( if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), - message: `Move workflow "${input.workflowName ?? input.workflowId}" to folder "${input.folderName ?? input.folderId}"?`, + message: `Move ${input.workflowName ?? input.workflowId} to folder ${input.folderName ?? input.folderId}`, severity: 'info' as const, }); } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b9e39370e88..184d6372d94 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5526,9 +5526,12 @@ "instanceAi.delegateCard.delegatingTo": "Delegating to", "instanceAi.delegateCard.briefing": "Briefing", "instanceAi.delegateCard.tools": "Tools", - "instanceAi.confirmation.approve": "Approve", + "instanceAi.confirmation.approve": "Allow once", + "instanceAi.confirmation.alwaysAllow": "Always allow", + "instanceAi.confirmation.alwaysAllowSuffix": "during this session", + "instanceAi.confirmation.allowPrompt": "Allow AI Assistant to {action}?", "instanceAi.confirmation.deny": "Deny", - "instanceAi.confirmation.approved": "Approved", + "instanceAi.confirmation.approved": "Allowed", "instanceAi.confirmation.denied": "Denied", "instanceAi.confirmation.deferred": "Skipped", "instanceAi.confirmation.pendingInline": "Waiting for approval", @@ -5537,18 +5540,18 @@ "instanceAi.confirmation.continue": "Continue", "instanceAi.gatewayConfirmation.allowForSession": "Allow for session", "instanceAi.gatewayConfirmation.allowOnce": "Allow once", - "instanceAi.gatewayConfirmation.denyOnce": "Deny once", - "instanceAi.gatewayConfirmation.prompt": "AI Assistant wants to access '{resources}' on Computer Use", + "instanceAi.gatewayConfirmation.denyOnce": "Deny", + "instanceAi.gatewayConfirmation.prompt": "Allow AI Assistant to access '{resources}' on Computer Use?", "instanceAi.askUser.placeholder": "Type your answer...", "instanceAi.askUser.submit": "Submit", "instanceAi.askUser.skip": "Skip", - "instanceAi.domainAccess.prompt": "AI Assistant wants to access {domain}", + "instanceAi.domainAccess.prompt": "Allow AI Assistant to access {domain}?", "instanceAi.domainAccess.allowOnce": "Allow once", "instanceAi.domainAccess.allowDomain": "Always allow {domain}", "instanceAi.domainAccess.allowAll": "Allow all domains", "instanceAi.domainAccess.deny": "Deny", "instanceAi.webSearch.prompt": "AI Assistant wants to search the web", - "instanceAi.webSearch.allowThread": "Always allow for this thread", + "instanceAi.webSearch.allowThread": "Always allow", "instanceAi.settings.permissions.fetchUrl.label": "Fetch URLs without approval", "instanceAi.credential.selected": "Credential selected", "instanceAi.credential.required": "This action requires a credential", @@ -5698,7 +5701,7 @@ "instanceAi.planReview.description": "Review the plan, then approve it to start building or request changes to revise it.", "instanceAi.planReview.feedbackPlaceholder": "Describe what should change before execution starts.", "instanceAi.planReview.requestChanges": "Request changes", - "instanceAi.planReview.approve": "Approve", + "instanceAi.planReview.approve": "Allow", "instanceAi.planReview.approved": "Plan approved", "instanceAi.message.retry": "Retry", "instanceAi.input.amendPlaceholder": "Amend the {role} agent...", @@ -5738,17 +5741,24 @@ "instanceAi.tools.workflows.get": "Reading workflow", "instanceAi.tools.workflows.get-as-code": "Reading workflow code", "instanceAi.tools.workflows.delete": "Archiving workflow", + "instanceAi.tools.workflows.delete.imperative": "archive workflow", + "instanceAi.tools.workflows.unarchive.imperative": "restore archived workflow", "instanceAi.tools.workflows.setup": "Setting up workflow", "instanceAi.tools.workflows.publish": "Publishing workflow", + "instanceAi.tools.workflows.publish.imperative": "publish workflow", "instanceAi.tools.workflows.unpublish": "Unpublishing workflow", + "instanceAi.tools.workflows.unpublish.imperative": "unpublish workflow", "instanceAi.tools.workflows.list-versions": "Listing workflow versions", "instanceAi.tools.workflows.get-version": "Reading workflow version", "instanceAi.tools.workflows.update-version": "Updating workflow version", + "instanceAi.tools.workflows.update-version.imperative": "update workflow version", "instanceAi.tools.workflows.restore-version": "Restoring workflow version", + "instanceAi.tools.workflows.restore-version.imperative": "restore workflow version", "instanceAi.tools.executions": "Executions", "instanceAi.tools.executions.list": "Checking executions", "instanceAi.tools.executions.get": "Getting execution result", "instanceAi.tools.executions.run": "Running workflow", + "instanceAi.tools.executions.run.imperative": "execute workflow", "instanceAi.tools.executions.debug": "Debugging execution", "instanceAi.tools.executions.get-node-output": "Reading node output", "instanceAi.tools.executions.stop": "Stopping execution", @@ -5756,6 +5766,7 @@ "instanceAi.tools.credentials.list": "Checking credentials", "instanceAi.tools.credentials.get": "Reading credential", "instanceAi.tools.credentials.delete": "Deleting credential", + "instanceAi.tools.credentials.delete.imperative": "delete credential", "instanceAi.tools.credentials.search-types": "Searching credential types", "instanceAi.tools.credentials.setup": "Setting up credentials", "instanceAi.tools.credentials.test": "Testing credential", @@ -5764,22 +5775,35 @@ "instanceAi.tools.data-tables.schema": "Reading table schema", "instanceAi.tools.data-tables.query": "Querying data", "instanceAi.tools.data-tables.create": "Creating data table", + "instanceAi.tools.data-tables.create.imperative": "create data table", "instanceAi.tools.data-tables.delete": "Deleting data table", + "instanceAi.tools.data-tables.delete.imperative": "delete data table", "instanceAi.tools.data-tables.add-column": "Adding table column", + "instanceAi.tools.data-tables.add-column.imperative": "add column", "instanceAi.tools.data-tables.delete-column": "Removing table column", + "instanceAi.tools.data-tables.delete-column.imperative": "delete column", "instanceAi.tools.data-tables.rename-column": "Renaming table column", + "instanceAi.tools.data-tables.rename-column.imperative": "rename column", "instanceAi.tools.data-tables.insert-rows": "Inserting rows", + "instanceAi.tools.data-tables.insert-rows.imperative": "insert rows", "instanceAi.tools.data-tables.update-rows": "Updating rows", + "instanceAi.tools.data-tables.update-rows.imperative": "update rows", "instanceAi.tools.data-tables.delete-rows": "Deleting rows", + "instanceAi.tools.data-tables.delete-rows.imperative": "delete rows", "instanceAi.tools.workspace": "Workspace", "instanceAi.tools.workspace.list-projects": "Listing projects", "instanceAi.tools.workspace.list-tags": "Listing tags", "instanceAi.tools.workspace.tag-workflow": "Tagging workflow", + "instanceAi.tools.workspace.tag-workflow.imperative": "tag workflow", "instanceAi.tools.workspace.cleanup-test-executions": "Cleaning up test executions", + "instanceAi.tools.workspace.cleanup-test-executions.imperative": "delete test executions", "instanceAi.tools.workspace.list-folders": "Listing folders", "instanceAi.tools.workspace.create-folder": "Creating folder", + "instanceAi.tools.workspace.create-folder.imperative": "create folder", "instanceAi.tools.workspace.delete-folder": "Deleting folder", + "instanceAi.tools.workspace.delete-folder.imperative": "delete folder", "instanceAi.tools.workspace.move-workflow-to-folder": "Moving workflow to folder", + "instanceAi.tools.workspace.move-workflow-to-folder.imperative": "move workflow", "instanceAi.tools.research": "Research", "instanceAi.tools.research.web-search": "Searching the web", "instanceAi.tools.research.fetch-url": "Fetching page", @@ -5845,6 +5869,7 @@ "instanceAi.tools.fetch-url": "Fetching page", "instanceAi.tools.research-with-agent": "Researching", "instanceAi.tools.build-workflow-with-agent": "Building workflow", + "instanceAi.tools.build-workflow-with-agent.imperative": "edit workflow", "instanceAi.tools.manage-data-tables-with-agent": "Managing data tables", "instanceAi.tools.browser-credential-setup": "Setting up credentials in browser", "instanceAi.tools.get-best-practices": "Loading best practices", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 8fa353ee185..fe43801b82f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -27,6 +27,7 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout'; import { provideThread, useInstanceAiStore } from './instanceAi.store'; +import { isPendingItemFloating } from './confirmationKinds'; import { useCanvasPreview } from './useCanvasPreview'; import { useEventRelay } from './useEventRelay'; import { useExecutionPushEvents } from './useExecutionPushEvents'; @@ -79,6 +80,13 @@ const builderAgents = computed(() => collectActiveBuilderAgents(thread.messages) // otherwise leave an empty wrapper in the list — filter them out. const displayedMessages = computed(() => thread.messages.filter(messageHasVisibleContent)); +// True when at least one pending confirmation should occupy the chat-input +// slot (generic approvals + domain/web-search access). Drives the swap +// between the input and the floating confirmation panel. +const hasFloatingConfirmation = computed(() => + thread.pendingConfirmations.some(isPendingItemFloating), +); + // --- Execution tracking via push events --- const executionTracking = useExecutionPushEvents(); const fixWithAiOffer = useFixWithAiOffer(); @@ -631,7 +639,11 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) { :agent-node="builder" /> - + + - +
@@ -675,17 +692,28 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) { @upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')" @dismiss="creditBanner.dismiss()" /> - +
+ + + + +
@@ -1031,6 +1059,13 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) { } } +// The leaving child is detached from layout (see `.input-swap-leave-active` +// below) so the slot follows the entering child's intrinsic height during +// the cross-fade. +.inputSwap { + position: relative; +} + .previewPanel { display: flex; flex-direction: column; @@ -1197,4 +1232,23 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) { .artifacts-panel-preview-leave-active { pointer-events: none; } + +// Cross-fade between the chat input and the floating confirmation panel. +// Default-mode cross-fade: both children co-exist briefly, the leaving one +// is absolute-positioned so it doesn't push the entering one down, and the +// slot sizes to the in-flow (entering) child. +.input-swap-enter-from, +.input-swap-leave-to { + opacity: 0; +} + +.input-swap-enter-active, +.input-swap-leave-active { + transition: opacity 120ms ease; +} + +.input-swap-leave-active { + position: absolute; + inset: 0; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ApprovalOptionList.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ApprovalOptionList.test.ts new file mode 100644 index 00000000000..04800820418 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/ApprovalOptionList.test.ts @@ -0,0 +1,68 @@ +import { render, fireEvent } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect } from 'vitest'; +import ApprovalOptionList, { + type ApprovalOption, +} from '@/features/ai/instanceAi/components/ApprovalOptionList.vue'; + +const OPTIONS: ApprovalOption[] = [ + { key: 'always-allow', icon: 'check', label: 'Always allow', testId: 'opt-always-allow' }, + { key: 'allow-once', icon: 'check', label: 'Allow once', testId: 'opt-allow-once' }, + { key: 'deny', icon: 'ban', label: 'Deny', withArrow: false, testId: 'opt-deny' }, +]; + +describe('ApprovalOptionList', () => { + it('pre-selects the first option on mount', () => { + const { getByTestId } = render(ApprovalOptionList, { props: { options: OPTIONS } }); + expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('true'); + expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('false'); + }); + + it('moves highlight on ArrowDown and stops at the last option', async () => { + const { getByTestId, getByRole } = render(ApprovalOptionList, { + props: { options: OPTIONS }, + }); + const listbox = getByRole('listbox'); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('true'); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true'); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true'); + }); + + it('moves highlight on ArrowUp and stops at the first option', async () => { + const { getByTestId, getByRole } = render(ApprovalOptionList, { + props: { options: OPTIONS }, + }); + const listbox = getByRole('listbox'); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + await fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('true'); + await fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + await fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('true'); + }); + + it('hovering a row moves the highlight to that row', async () => { + const { getByTestId } = render(ApprovalOptionList, { props: { options: OPTIONS } }); + await fireEvent.mouseEnter(getByTestId('opt-deny')); + expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true'); + expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('false'); + }); + + it('emits select with the highlighted option on Enter', async () => { + const { getByRole, emitted } = render(ApprovalOptionList, { props: { options: OPTIONS } }); + const listbox = getByRole('listbox'); + await fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + await fireEvent.keyDown(listbox, { key: 'Enter' }); + expect(emitted('select')).toEqual([['allow-once']]); + }); + + it('emits select on click', async () => { + const { getByTestId, emitted } = render(ApprovalOptionList, { props: { options: OPTIONS } }); + await userEvent.click(getByTestId('opt-deny')); + expect(emitted('select')).toEqual([['deny']]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts index 29f7bb98653..10fd5436a96 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts @@ -167,7 +167,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { getByTestId } = renderComponent(); + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); await userEvent.click(getByTestId('instance-ai-panel-confirm-approve')); expect(mockTelemetryTrack).toHaveBeenCalledWith( @@ -179,7 +179,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { provided_inputs: [ { label: 'Run this workflow?', - options: ['approve', 'deny'], + options: ['approve', 'deny', 'approve_always'], option_chosen: 'approve', }, ], @@ -197,7 +197,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { getByTestId } = renderComponent(); + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); await userEvent.click(getByTestId('instance-ai-panel-confirm-approve')); expect(confirmSpy).toHaveBeenCalledWith('req-explicit-approval', { @@ -214,7 +214,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { getByTestId } = renderComponent(); + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); await userEvent.click(getByTestId('instance-ai-panel-confirm-deny')); expect(mockTelemetryTrack).toHaveBeenCalledWith( @@ -224,13 +224,147 @@ describe('InstanceAiConfirmationPanel telemetry', () => { provided_inputs: [ { label: 'Delete this file?', - options: ['approve', 'deny'], + options: ['approve', 'deny', 'approve_always'], option_chosen: 'deny', }, ], }), ); }); + + it('tracks always-allow click and records the key on the runtime', async () => { + injectPendingConfirmation( + thread, + { + requestId: 'req-always', + severity: 'info', + message: 'Run this workflow?', + }, + { action: 'run' }, + ); + vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); + const addKeySpy = vi.spyOn(thread, 'addAlwaysAllowKey'); + + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); + await userEvent.click(getByTestId('instance-ai-panel-confirm-always-allow')); + + expect(addKeySpy).toHaveBeenCalledWith('test-tool', { action: 'run' }); + expect(mockTelemetryTrack).toHaveBeenCalledWith( + 'User finished providing input', + expect.objectContaining({ + type: 'approval', + provided_inputs: [ + { + label: 'Run this workflow?', + options: ['approve', 'deny', 'approve_always'], + option_chosen: 'approve_always', + }, + ], + }), + ); + }); + + it('does not grant the session-allow key when always-allow POST fails', async () => { + injectPendingConfirmation( + thread, + { + requestId: 'req-always-fail', + severity: 'info', + message: 'Run this workflow?', + }, + { action: 'run' }, + ); + vi.spyOn(thread, 'confirmAction').mockResolvedValue(false); + const addKeySpy = vi.spyOn(thread, 'addAlwaysAllowKey'); + const resolveSpy = vi.spyOn(thread, 'resolveConfirmation'); + + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); + await userEvent.click(getByTestId('instance-ai-panel-confirm-always-allow')); + + expect(addKeySpy).not.toHaveBeenCalled(); + expect(resolveSpy).not.toHaveBeenCalled(); + expect(mockTelemetryTrack).not.toHaveBeenCalled(); + }); + + it('does not resolve the confirmation when an approve POST fails', async () => { + injectPendingConfirmation(thread, { + requestId: 'req-approve-fail', + severity: 'info', + message: 'Run this workflow?', + }); + vi.spyOn(thread, 'confirmAction').mockResolvedValue(false); + const resolveSpy = vi.spyOn(thread, 'resolveConfirmation'); + + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); + await userEvent.click(getByTestId('instance-ai-panel-confirm-approve')); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(mockTelemetryTrack).not.toHaveBeenCalled(); + }); + + it('ignores repeated clicks while the confirm POST is in flight', async () => { + injectPendingConfirmation(thread, { + requestId: 'req-double-click', + severity: 'info', + message: 'Run this workflow?', + }); + let resolvePost: (value: boolean) => void = () => {}; + const confirmSpy = vi.spyOn(thread, 'confirmAction').mockReturnValue( + new Promise((resolve) => { + resolvePost = resolve; + }), + ); + + const { getByTestId } = renderComponent({ props: { kind: 'floating' } }); + const approveButton = getByTestId('instance-ai-panel-confirm-approve'); + await userEvent.click(approveButton); + await userEvent.click(approveButton); + await userEvent.click(approveButton); + + expect(confirmSpy).toHaveBeenCalledTimes(1); + + resolvePost(true); + }); + + it('hides always-allow on destructive confirmations and narrows the option set', async () => { + injectPendingConfirmation(thread, { + requestId: 'req-destructive', + severity: 'destructive', + message: 'Delete workflow?', + }); + vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); + + const { getByTestId, queryByTestId } = renderComponent({ props: { kind: 'floating' } }); + + expect(queryByTestId('instance-ai-panel-confirm-always-allow')).toBeNull(); + + await userEvent.click(getByTestId('instance-ai-panel-confirm-approve')); + + expect(mockTelemetryTrack).toHaveBeenCalledWith( + 'User finished providing input', + expect.objectContaining({ + provided_inputs: [ + { + label: 'Delete workflow?', + options: ['approve', 'deny'], + option_chosen: 'approve', + }, + ], + }), + ); + }); + + it('renders nothing when mounted as inline for a floating-eligible confirmation', () => { + injectPendingConfirmation(thread, { + requestId: 'req-inline-skip', + severity: 'info', + message: 'Run this workflow?', + }); + + const { queryByTestId } = renderComponent({ props: { kind: 'inline' } }); + expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull(); + expect(queryByTestId('instance-ai-confirmation-panel')).toBeNull(); + }); }); describe('text input confirmation', () => { @@ -243,7 +377,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { container } = renderComponent(); + const { container } = renderComponent({ props: { kind: 'inline' } }); const input = container.querySelector('input[type="text"]') as HTMLInputElement; await userEvent.type(input, 'My Workflow'); await userEvent.keyboard('{Enter}'); @@ -275,7 +409,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { container } = renderComponent(); + const { container } = renderComponent({ props: { kind: 'inline' } }); // Find the skip button (shown when input is empty) const buttons = container.querySelectorAll('button'); const skipBtn = Array.from(buttons).find( @@ -312,7 +446,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - const { getByTestId, queryByTestId } = renderComponent(); + const { getByTestId, queryByTestId } = renderComponent({ props: { kind: 'inline' } }); expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull(); expect(queryByTestId('instance-ai-panel-confirm-deny')).toBeNull(); @@ -371,7 +505,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { injectPendingConfirmation(thread, questionsConfirmation); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - renderComponent(); + renderComponent({ props: { kind: 'inline' } }); const answers: QuestionAnswer[] = [ { @@ -433,7 +567,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { injectPendingConfirmation(thread, questionsConfirmation); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - renderComponent(); + renderComponent({ props: { kind: 'inline' } }); const answers: QuestionAnswer[] = [ { @@ -476,7 +610,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { injectPendingConfirmation(thread, questionsConfirmation); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - renderComponent(); + renderComponent({ props: { kind: 'inline' } }); const answers: QuestionAnswer[] = [ { @@ -517,7 +651,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { injectPendingConfirmation(thread, questionsConfirmation); vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); - renderComponent(); + renderComponent({ props: { kind: 'inline' } }); const answers: QuestionAnswer[] = [ { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts index 17abb2b9093..a3bc983db2a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts @@ -54,6 +54,16 @@ const InstanceAiInputStub = defineComponent({ }, }); +const InstanceAiConfirmationPanelStub = defineComponent({ + name: 'InstanceAiConfirmationPanelStub', + props: { + kind: { type: String, required: true }, + }, + setup(props) { + return () => h('div', { 'data-test-id': `instance-ai-confirmation-panel-${props.kind}` }); + }, +}); + const renderView = createComponentRenderer(InstanceAiThreadView, { global: { provide: { @@ -61,6 +71,7 @@ const renderView = createComponentRenderer(InstanceAiThreadView, { }, stubs: { InstanceAiInput: InstanceAiInputStub, + InstanceAiConfirmationPanel: InstanceAiConfirmationPanelStub, }, }, }); @@ -153,6 +164,65 @@ describe('InstanceAiThreadView', () => { expect(thread.connectSSE).toHaveBeenCalledWith(); }); + it('keeps the chat input visible when no floating-eligible confirmation is pending', () => { + const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } }); + + expect(getByTestId('instance-ai-input-stub')).toBeTruthy(); + expect(queryByTestId('instance-ai-confirmation-panel-floating')).toBeNull(); + // Inline mount is always present so non-floating forms can render. + expect(getByTestId('instance-ai-confirmation-panel-inline')).toBeTruthy(); + }); + + it('swaps the chat input for the floating panel when a generic approval is pending', () => { + thread.pendingConfirmations = [ + { + messageId: 'msg-floating', + agentNode: { agentId: 'agent-1', role: 'orchestrator' }, + toolCall: { + toolCallId: 'tc-1', + toolName: 'workflows', + args: { action: 'run' }, + isLoading: true, + confirmationStatus: 'pending', + confirmation: { requestId: 'req-1', severity: 'info', message: 'Run?' }, + }, + }, + ] as unknown as ThreadRuntime['pendingConfirmations']; + + const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } }); + + expect(getByTestId('instance-ai-confirmation-panel-floating')).toBeTruthy(); + expect(queryByTestId('instance-ai-input-stub')).toBeNull(); + }); + + it('keeps the chat input visible when only inline confirmations are pending', () => { + thread.pendingConfirmations = [ + { + messageId: 'msg-questions', + agentNode: { agentId: 'agent-1', role: 'orchestrator' }, + toolCall: { + toolCallId: 'tc-q', + toolName: 'ask-user', + args: {}, + isLoading: true, + confirmationStatus: 'pending', + confirmation: { + requestId: 'req-q', + severity: 'info', + message: 'Pick', + inputType: 'questions', + questions: [{ id: 'q1', question: 'Pick?', type: 'single', options: ['a'] }], + }, + }, + }, + ] as unknown as ThreadRuntime['pendingConfirmations']; + + const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } }); + + expect(getByTestId('instance-ai-input-stub')).toBeTruthy(); + expect(queryByTestId('instance-ai-confirmation-panel-floating')).toBeNull(); + }); + it('connects the route thread when navigating to a known thread', async () => { thread.sseState = 'disconnected'; store.threads = [ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts index fa6068417de..17517cd06b4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts @@ -967,3 +967,152 @@ describe('createThreadRuntime - gateway resource-decision confirmation', () => { expect(mockPostConfirmation).toHaveBeenCalledOnce(); }); }); + +describe('createThreadRuntime - session always-allow', () => { + let registry: RuntimeRegistry; + + beforeEach(() => { + setupRuntimePinia(); + registry = createRuntimeRegistry(); + activeThreadId = 'thread-always-allow'; + mockPostConfirmation.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function pushPendingApproval( + runtime: ThreadRuntime, + opts: { + messageId: string; + requestId: string; + toolName: string; + args?: Record; + severity?: 'info' | 'warning' | 'destructive'; + }, + ): void { + runtime.messages.push({ + id: opts.messageId, + role: 'assistant', + createdAt: new Date().toISOString(), + content: '', + reasoning: '', + isStreaming: false, + agentTree: { + agentId: 'agent-1', + role: 'orchestrator', + status: 'active', + textContent: '', + reasoning: '', + timeline: [], + children: [], + toolCalls: [ + { + toolCallId: `tc-${opts.requestId}`, + toolName: opts.toolName, + args: opts.args ?? {}, + isLoading: true, + confirmationStatus: 'pending', + confirmation: { + requestId: opts.requestId, + severity: opts.severity ?? 'info', + message: 'Approve?', + }, + }, + ], + }, + }); + } + + it('auto-approves matching generic-eligible confirmations after key is added', async () => { + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('workflows', { action: 'run' }); + + pushPendingApproval(runtime, { + messageId: 'msg-auto', + requestId: 'req-auto', + toolName: 'workflows', + args: { action: 'run' }, + }); + + await vi.waitFor(() => { + expect(runtime.resolvedConfirmationIds.get('req-auto')).toBe('approved'); + }); + expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-auto', { + kind: 'approval', + approved: true, + }); + }); + + it('does not auto-approve destructive confirmations even when the key matches', async () => { + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('workflows', { action: 'delete' }); + + pushPendingApproval(runtime, { + messageId: 'msg-destructive', + requestId: 'req-destructive', + toolName: 'workflows', + args: { action: 'delete' }, + severity: 'destructive', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(runtime.resolvedConfirmationIds.has('req-destructive')).toBe(false); + expect(mockPostConfirmation).not.toHaveBeenCalled(); + }); + + it('distinguishes submit-workflow create vs update grants by workflowId presence', async () => { + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('submit-workflow', {}); + + pushPendingApproval(runtime, { + messageId: 'msg-create', + requestId: 'req-create', + toolName: 'submit-workflow', + args: {}, + }); + await vi.waitFor(() => { + expect(runtime.resolvedConfirmationIds.get('req-create')).toBe('approved'); + }); + + pushPendingApproval(runtime, { + messageId: 'msg-update', + requestId: 'req-update', + toolName: 'submit-workflow', + args: { workflowId: 'wf-1' }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(runtime.resolvedConfirmationIds.has('req-update')).toBe(false); + }); + + it('clears keys on resetState', () => { + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('workflows', { action: 'run' }); + expect(runtime.sessionAlwaysAllowKeys.size).toBe(1); + + runtime.resetState(); + expect(runtime.sessionAlwaysAllowKeys.size).toBe(0); + }); + + it('keeps the confirmation pending when auto-approve POST fails', async () => { + mockPostConfirmation.mockRejectedValueOnce(new Error('network down')); + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('workflows', { action: 'run' }); + + pushPendingApproval(runtime, { + messageId: 'msg-fail', + requestId: 'req-fail', + toolName: 'workflows', + args: { action: 'run' }, + }); + + await vi.waitFor(() => { + expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-fail', { + kind: 'approval', + approved: true, + }); + }); + expect(runtime.resolvedConfirmationIds.has('req-fail')).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ApprovalOptionList.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ApprovalOptionList.vue new file mode 100644 index 00000000000..b55a2641fc7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ApprovalOptionList.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConfirmationPreview.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConfirmationPreview.vue index 45f7be2db93..0891ee350eb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConfirmationPreview.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConfirmationPreview.vue @@ -11,7 +11,6 @@ color: var(--color--text); word-break: break-all; padding: var(--spacing--2xs); - background: light-dark(var(--color--background), var(--color--neutral-850)); border-radius: var(--radius); border: var(--border); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue index a682956c530..f3190d86a85 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue @@ -1,12 +1,10 @@ @@ -100,25 +112,7 @@ function onDropdownSelect(action: string) { {{ previewText }} - - - - + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue index e2aa52270f9..1fde4dc0bde 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue @@ -1,13 +1,14 @@