refactor: Split up instance-ai confirmation endpoint DTO by action (#29179)

This commit is contained in:
Charlie Kolb 2026-05-04 12:47:38 +02:00 committed by GitHub
parent c28d501ba1
commit f775604c25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 457 additions and 324 deletions

View File

@ -25,7 +25,11 @@ export type {
AiGatewayUsageResponse,
} from './ai/ai-gateway-usage-response.dto';
export { InstanceAiConfirmRequestDto } from './instance-ai/instance-ai-confirm-request.dto';
export {
InstanceAiConfirmRequestDto,
type InstanceAiConfirmRequest,
type InstanceAiConfirmRequestKind,
} from './instance-ai/instance-ai-confirm-request.dto';
export { InstanceAiFeedbackRequestDto } from './instance-ai/instance-ai-feedback-request.dto';
export { InstanceAiRenameThreadRequestDto } from './instance-ai/instance-ai-rename-thread-request.dto';

View File

@ -0,0 +1,151 @@
import {
InstanceAiConfirmRequestDto,
type InstanceAiConfirmRequest,
} from '../instance-ai-confirm-request.dto';
/**
* The shapes in this file mirror what each frontend call site sends (see
* components/ and composables/useSetupActions.ts in editor-ui). If a call site
* changes the body shape, its branch here must change too that is the whole
* point of keeping this test.
*/
describe('InstanceAiConfirmRequestDto', () => {
describe('accepts each frontend-sent payload shape', () => {
const cases: Array<[label: string, payload: InstanceAiConfirmRequest]> = [
// InstanceAiConfirmationPanel: handleConfirm / handleApproveAll / handlePlanApprove / handleTextSkip
['approval approve (no input)', { kind: 'approval', approved: true }],
['approval deny (no input)', { kind: 'approval', approved: false }],
// InstanceAiConfirmationPanel: handleTextSubmit
[
'approval with userInput (text submit)',
{ kind: 'approval', approved: true, userInput: 'some typed answer' },
],
// InstanceAiConfirmationPanel: handlePlanRequestChanges + AgentTimeline feedback
[
'approval deny with userInput (plan feedback)',
{ kind: 'approval', approved: false, userInput: 'please revise step 3' },
],
// InstanceAiConfirmationPanel: handleQuestionsSubmit
[
'questions with mixed answers',
{
kind: 'questions',
answers: [
{ questionId: 'q1', selectedOptions: ['opt-a'] },
{ questionId: 'q2', selectedOptions: ['opt-b', 'opt-c'], customText: 'extra' },
{ questionId: 'q3', selectedOptions: [], skipped: true },
],
},
],
// InstanceAiCredentialSetup: handleContinue
[
'credentialSelection with credential map',
{
kind: 'credentialSelection',
credentials: { slackApi: 'cred-1', githubApi: 'cred-2' },
},
],
// DomainAccessApproval: handleAction (primary path — with action)
[
'domainAccessApprove with allow_domain',
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' },
],
[
'domainAccessApprove with allow_once',
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_once' },
],
[
'domainAccessApprove with allow_all',
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_all' },
],
// DomainAccessApproval: handleAction (deny path)
['domainAccessDeny', { kind: 'domainAccessDeny' }],
// confirmResourceDecision (store)
[
'resourceDecision with arbitrary decision token',
{ kind: 'resourceDecision', resourceDecision: 'allowForSession' },
],
// useSetupActions: handleApply
[
'setupWorkflowApply (full payload)',
{
kind: 'setupWorkflowApply',
nodeCredentials: {
'Slack Node': { slackApi: 'cred-1' },
'GitHub Node': { githubApi: 'cred-2' },
},
nodeParameters: {
'Slack Node': { channel: '#general' },
},
},
],
['setupWorkflowApply (no node credentials)', { kind: 'setupWorkflowApply' }],
// useSetupActions: handleTestTrigger
[
'setupWorkflowTestTrigger (with node credentials)',
{
kind: 'setupWorkflowTestTrigger',
testTriggerNode: 'Webhook',
nodeCredentials: { Webhook: { httpHeaderAuth: 'cred-3' } },
nodeParameters: { Webhook: { path: '/trigger' } },
},
],
[
'setupWorkflowTestTrigger (minimal)',
{ kind: 'setupWorkflowTestTrigger', testTriggerNode: 'Webhook' },
],
];
test.each(cases)('%s', (_label, payload) => {
const result = InstanceAiConfirmRequestDto.safeParse(payload);
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual(payload);
});
});
describe('rejects invalid payloads', () => {
test('missing kind discriminator', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ approved: true });
expect(result.success).toBe(false);
});
test('unknown kind', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'bogus', approved: true });
expect(result.success).toBe(false);
});
test('questions without answers array', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'questions' });
expect(result.success).toBe(false);
});
test('credentialSelection without credentials map', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'credentialSelection' });
expect(result.success).toBe(false);
});
test('resourceDecision without decision', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'resourceDecision' });
expect(result.success).toBe(false);
});
test('setupWorkflowTestTrigger without testTriggerNode', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'setupWorkflowTestTrigger' });
expect(result.success).toBe(false);
});
test('domainAccessAction must be a known value', () => {
const result = InstanceAiConfirmRequestDto.safeParse({
kind: 'domainAccessApprove',
domainAccessAction: 'allow_never',
});
expect(result.success).toBe(false);
});
test('domainAccessApprove without domainAccessAction', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'domainAccessApprove' });
expect(result.success).toBe(false);
});
});
});

View File

@ -1,28 +1,92 @@
import { z } from 'zod';
import { domainAccessActionSchema } from '../../schemas/instance-ai.schema';
import { Z } from '../../zod-class';
export class InstanceAiConfirmRequestDto extends Z.class({
/**
* Plain approval/denial. Also carries optional `userInput` for:
* - text-input confirmations (inputType='text')
* - plan-review feedback accompanying approve/request-changes
* - deferring/skipping credential or workflow setup wizards (`approved: false`)
*/
const approvalConfirmSchema = z.object({
kind: z.literal('approval'),
approved: z.boolean(),
credentialId: z.string().optional(),
credentials: z.record(z.string()).optional(),
nodeCredentials: z.record(z.record(z.string())).optional(),
autoSetup: z.object({ credentialType: z.string() }).optional(),
userInput: z.string().optional(),
domainAccessAction: domainAccessActionSchema.optional(),
action: z.enum(['apply', 'test-trigger']).optional(),
nodeParameters: z.record(z.record(z.unknown())).optional(),
testTriggerNode: z.string().optional(),
answers: z
.array(
z.object({
questionId: z.string(),
selectedOptions: z.array(z.string()),
customText: z.string().optional(),
skipped: z.boolean().optional(),
}),
)
.optional(),
resourceDecision: z.string().optional(),
}) {}
});
/** Q&A wizard submission (inputType='questions'). */
const questionsConfirmSchema = z.object({
kind: z.literal('questions'),
answers: z.array(
z.object({
questionId: z.string(),
selectedOptions: z.array(z.string()),
customText: z.string().optional(),
skipped: z.boolean().optional(),
}),
),
});
/** Map of credential type → credential ID (e.g. `{ slackApi: 'cred-1', githubApi: 'cred-2' }`). */
const credentialIdByTypeSchema = z.record(z.string());
const credentialSelectionConfirmSchema = z.object({
kind: z.literal('credentialSelection'),
credentials: credentialIdByTypeSchema,
});
/** Domain-access approval — `domainAccessAction` carries which scope the user picked. */
const domainAccessApproveSchema = z.object({
kind: z.literal('domainAccessApprove'),
domainAccessAction: domainAccessActionSchema,
});
/** Domain-access denial — no further input. */
const domainAccessDenySchema = z.object({
kind: z.literal('domainAccessDeny'),
});
/** Gateway resource-access decision (inputType='resource-decision'). Approval is implied.
* `resourceDecision` is one of the opaque tokens listed in the request's `options[]` array
* (e.g. `'denyOnce'`, `'allowOnce'`, `'allowForSession'`) the daemon defines the vocabulary,
* so we keep this as a string rather than a fixed enum. */
const resourceDecisionConfirmSchema = z.object({
kind: z.literal('resourceDecision'),
resourceDecision: z.string(),
});
/** Per-node credential map: `Record<nodeName, Record<credentialType, credentialId>>`. */
const nodeCredentialsRecord = z.record(credentialIdByTypeSchema).optional();
/** Per-node parameter map: `Record<nodeName, Record<paramName, value>>`. */
const nodeParametersRecord = z.record(z.record(z.unknown())).optional();
/** Workflow-setup wizard: apply the chosen credentials/parameters. Approval is implied;
* the service maps this to `action: 'apply'` for the underlying Mastra resume schema. */
const setupWorkflowApplyConfirmSchema = z.object({
kind: z.literal('setupWorkflowApply'),
nodeCredentials: nodeCredentialsRecord,
nodeParameters: nodeParametersRecord,
});
/** Workflow-setup wizard: run a test-trigger against a specific node. Approval is implied;
* the service maps this to `action: 'test-trigger'` for the underlying Mastra resume schema. */
const setupWorkflowTestTriggerConfirmSchema = z.object({
kind: z.literal('setupWorkflowTestTrigger'),
testTriggerNode: z.string(),
nodeCredentials: nodeCredentialsRecord,
nodeParameters: nodeParametersRecord,
});
export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [
approvalConfirmSchema,
questionsConfirmSchema,
credentialSelectionConfirmSchema,
domainAccessApproveSchema,
domainAccessDenySchema,
resourceDecisionConfirmSchema,
setupWorkflowApplyConfirmSchema,
setupWorkflowTestTriggerConfirmSchema,
]);
export type InstanceAiConfirmRequest = z.infer<typeof InstanceAiConfirmRequestDto>;
export type InstanceAiConfirmRequestKind = InstanceAiConfirmRequest['kind'];

View File

@ -343,7 +343,6 @@ export type {
InstanceAiEvent,
InstanceAiAttachment,
InstanceAiSendMessageResponse,
InstanceAiConfirmResponse,
InstanceAiToolCallState,
InstanceAiAgentNode,
InstanceAiTimelineEntry,

View File

@ -601,28 +601,6 @@ export interface InstanceAiSendMessageResponse {
runId: string;
}
export interface InstanceAiConfirmResponse {
approved: boolean;
credentialId?: string;
credentials?: Record<string, string>;
/** Per-node credential assignments: `{ nodeName: { credType: credId } }`.
* Preferred over `credentials` when present enables card-scoped selection. */
nodeCredentials?: Record<string, Record<string, string>>;
autoSetup?: { credentialType: string };
userInput?: string;
domainAccessAction?: DomainAccessAction;
resourceDecision?: string;
action?: 'apply' | 'test-trigger';
nodeParameters?: Record<string, Record<string, unknown>>;
testTriggerNode?: string;
answers?: Array<{
questionId: string;
selectedOptions: string[];
customText?: string;
skipped?: boolean;
}>;
}
// ---------------------------------------------------------------------------
// Frontend store types (shared so both sides agree on structure)
// ---------------------------------------------------------------------------

View File

@ -7,6 +7,7 @@
// ---------------------------------------------------------------------------
import type {
InstanceAiConfirmRequest,
InstanceAiRichMessagesResponse,
InstanceAiEvalExecutionResult,
InstanceAiEvalSubAgentRequest,
@ -121,16 +122,12 @@ export class N8nClient {
/**
* Confirm or reject an action requested by the agent.
* POST /rest/instance-ai/confirm/:requestId
* body: { approved, mockCredentials?, credentialId?, ... }
* body: kind-tagged `InstanceAiConfirmRequest` discriminated union.
*/
async confirmAction(
requestId: string,
approved: boolean,
options?: { mockCredentials?: boolean },
): Promise<void> {
async confirmAction(requestId: string, payload: InstanceAiConfirmRequest): Promise<void> {
await this.fetch(`/rest/instance-ai/confirm/${requestId}`, {
method: 'POST',
body: { approved, ...options },
body: payload,
});
}

View File

@ -6,7 +6,7 @@
// LLM-mocked HTTP, checklist verification, and result aggregation.
// ---------------------------------------------------------------------------
import type { InstanceAiEvalExecutionResult } from '@n8n/api-types';
import type { InstanceAiConfirmRequest, InstanceAiEvalExecutionResult } from '@n8n/api-types';
import crypto from 'node:crypto';
import { type EvalLogger } from './logger';
@ -727,9 +727,7 @@ async function processConfirmationRequests(config: WaitConfig): Promise<void> {
}
try {
// Always offer mock credentials — the eval runner doesn't have real
// credentials for most services, so tell Instance AI to use mock data
await config.client.confirmAction(requestId, true, { mockCredentials: true });
await config.client.confirmAction(requestId, buildAutoApprovePayload(event));
config.approvedRequests.add(requestId);
confirmationRetries.delete(requestId);
} catch (error: unknown) {
@ -742,6 +740,40 @@ async function processConfirmationRequests(config: WaitConfig): Promise<void> {
}
}
/** Map a confirmation-request event to the most-permissive approval payload of the
* matching kind. The eval runner has no real credentials and no human in the loop
* we just need a structurally-valid payload that lets the agent proceed. */
function buildAutoApprovePayload(event: CapturedEvent): InstanceAiConfirmRequest {
const payload = getNestedRecord(event.data, 'payload') ?? {};
if (getNestedRecord(payload, 'domainAccess')) {
return { kind: 'domainAccessApprove', domainAccessAction: 'allow_all' };
}
const resourceDecision = getNestedRecord(payload, 'resourceDecision');
if (resourceDecision) {
const options = Array.isArray(resourceDecision.options)
? (resourceDecision.options as unknown[]).filter((o): o is string => typeof o === 'string')
: [];
const allowOption = options.find((o) => o.toLowerCase().includes('allow')) ?? options[0];
return { kind: 'resourceDecision', resourceDecision: allowOption ?? 'allowOnce' };
}
if (Array.isArray(payload.setupRequests)) {
return { kind: 'setupWorkflowApply' };
}
if (Array.isArray(payload.credentialRequests)) {
return { kind: 'credentialSelection', credentials: {} };
}
if (payload.inputType === 'questions') {
return { kind: 'questions', answers: [] };
}
return { kind: 'approval', approved: true };
}
// ---------------------------------------------------------------------------
// Event helpers
// ---------------------------------------------------------------------------

View File

@ -691,7 +691,7 @@ describe('RunStateRegistry', () => {
registry.registerPendingConfirmation('req-1', pending);
const data: ConfirmationData = { approved: true, credentialId: 'cred-1' };
const data: ConfirmationData = { approved: true, userInput: 'looks good' };
const result = registry.resolvePendingConfirmation('user-1', 'req-1', data);
expect(result).toBe(true);

View File

@ -24,12 +24,16 @@ export interface SuspendedRunState<TUser = unknown> extends ActiveRunState {
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string };
}
/**
* Flat confirmation payload consumed by Mastra tool `resumeSchema`s and sub-agent HITL.
* The service layer constructs this from the typed `InstanceAiConfirmRequest` discriminated
* union sent by the frontend only one subset of fields is populated per call, matching
* the confirmation kind that was originally requested.
*/
export interface ConfirmationData {
approved: boolean;
credentialId?: string;
credentials?: Record<string, string>;
nodeCredentials?: Record<string, Record<string, string>>;
autoSetup?: { credentialType: string };
userInput?: string;
domainAccessAction?: string;
action?: 'apply' | 'test-trigger';

View File

@ -29,7 +29,7 @@ import type {
InstanceAiAdminSettingsUpdateRequest,
InstanceAiSendMessageRequest,
InstanceAiCorrectTaskRequest,
InstanceAiConfirmRequestDto,
InstanceAiConfirmRequest,
InstanceAiEnsureThreadRequest,
InstanceAiThreadMessagesQuery,
InstanceAiUserPreferencesUpdateRequest,
@ -497,39 +497,34 @@ describe('InstanceAiController', () => {
it('should resolve confirmation', async () => {
instanceAiService.resolveConfirmation.mockResolvedValue(true);
const body = mock<InstanceAiConfirmRequestDto>({ approved: true });
const body: InstanceAiConfirmRequest = { kind: 'approval', approved: true };
const reqWithBody = { ...req, body } as AuthenticatedRequest;
const result = await controller.confirm(req, res, 'req-1', body);
const result = await controller.confirm(reqWithBody, res, 'req-1');
expect(result).toEqual({ ok: true });
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(
USER_ID,
'req-1',
expect.objectContaining({ approved: true }),
);
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(USER_ID, 'req-1', body);
});
it('should pass resourceDecision through to resolveConfirmation', async () => {
instanceAiService.resolveConfirmation.mockResolvedValue(true);
const body = mock<InstanceAiConfirmRequestDto>({
approved: true,
const body: InstanceAiConfirmRequest = {
kind: 'resourceDecision',
resourceDecision: 'allowOnce',
});
};
const reqWithBody = { ...req, body } as AuthenticatedRequest;
await controller.confirm(req, res, 'req-1', body);
await controller.confirm(reqWithBody, res, 'req-1');
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(
USER_ID,
'req-1',
expect.objectContaining({ resourceDecision: 'allowOnce' }),
);
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(USER_ID, 'req-1', body);
});
it('should throw NotFoundError when confirmation not found', async () => {
instanceAiService.resolveConfirmation.mockResolvedValue(false);
const body = mock<InstanceAiConfirmRequestDto>({ approved: false });
const body: InstanceAiConfirmRequest = { kind: 'approval', approved: false };
const reqWithBody = { ...req, body } as AuthenticatedRequest;
await expect(controller.confirm(req, res, 'req-1', body)).rejects.toThrow(NotFoundError);
await expect(controller.confirm(reqWithBody, res, 'req-1')).rejects.toThrow(NotFoundError);
});
});

View File

@ -325,27 +325,21 @@ export class InstanceAiController {
@Post('/confirm/:requestId')
@GlobalScope('instanceAi:message')
async confirm(
req: AuthenticatedRequest,
_res: Response,
@Param('requestId') requestId: string,
@Body body: InstanceAiConfirmRequestDto,
) {
async confirm(req: AuthenticatedRequest, _res: Response, @Param('requestId') requestId: string) {
this.requireInstanceAiEnabled();
const resolved = await this.instanceAiService.resolveConfirmation(req.user.id, requestId, {
approved: body.approved,
credentialId: body.credentialId,
credentials: body.credentials,
nodeCredentials: body.nodeCredentials,
autoSetup: body.autoSetup,
userInput: body.userInput,
domainAccessAction: body.domainAccessAction,
action: body.action,
nodeParameters: body.nodeParameters,
testTriggerNode: body.testTriggerNode,
answers: body.answers,
resourceDecision: body.resourceDecision,
});
// Manual parse: `@Body` decorator can't resolve zod discriminated unions via reflection,
// so validate the request body against the union schema directly.
const parseResult = InstanceAiConfirmRequestDto.safeParse(req.body);
if (!parseResult.success) {
throw new BadRequestError(parseResult.error.errors[0].message);
}
const resolved = await this.instanceAiService.resolveConfirmation(
req.user.id,
requestId,
parseResult.data,
);
if (!resolved) {
throw new NotFoundError('Confirmation request not found or not authorized');
}

View File

@ -3,6 +3,7 @@ import {
applyBranchReadOnlyOverrides,
buildProxyHeaders,
type InstanceAiAttachment,
type InstanceAiConfirmRequest,
type InstanceAiEvent,
type InstanceAiThreadStatusResponse,
type InstanceAiGatewayCapabilities,
@ -143,6 +144,45 @@ interface MessageTraceFinalization {
error?: string;
}
/** Collapse the frontend's typed confirmation union into the flat payload
* consumed by Mastra tool resume schemas and sub-agent HITL. Only the fields
* relevant to the submitted kind are populated everything else stays undefined.
*
* Most kinds carry implicit approval (you wouldn't be submitting answers,
* selected credentials, or a setup action otherwise) only `approval` and
* `domainAccessDeny` actually carry a denial path. */
function toConfirmationData(request: InstanceAiConfirmRequest): ConfirmationData {
switch (request.kind) {
case 'approval':
return { approved: request.approved, userInput: request.userInput };
case 'domainAccessApprove':
return { approved: true, domainAccessAction: request.domainAccessAction };
case 'domainAccessDeny':
return { approved: false };
case 'questions':
return { approved: true, answers: request.answers };
case 'credentialSelection':
return { approved: true, credentials: request.credentials };
case 'resourceDecision':
return { approved: true, resourceDecision: request.resourceDecision };
case 'setupWorkflowApply':
return {
approved: true,
action: 'apply',
nodeCredentials: request.nodeCredentials,
nodeParameters: request.nodeParameters,
};
case 'setupWorkflowTestTrigger':
return {
approved: true,
action: 'test-trigger',
testTriggerNode: request.testTriggerNode,
nodeCredentials: request.nodeCredentials,
nodeParameters: request.nodeParameters,
};
}
}
@Service()
export class InstanceAiService {
private readonly mcpClientManager = new McpClientManager();
@ -2562,8 +2602,10 @@ export class InstanceAiService {
async resolveConfirmation(
requestingUserId: string,
requestId: string,
data: ConfirmationData,
request: InstanceAiConfirmRequest,
): Promise<boolean> {
const data = toConfirmationData(request);
if (this.runState.resolvePendingConfirmation(requestingUserId, requestId, data)) {
this.logger.debug('Resolved pending confirmation (sub-agent HITL)', {
requestId,
@ -2614,9 +2656,7 @@ export class InstanceAiService {
const credentialsPayload = data.nodeCredentials ?? data.credentials;
const resumeData = {
approved: data.approved,
...(data.credentialId ? { credentialId: data.credentialId } : {}),
...(credentialsPayload ? { credentials: credentialsPayload } : {}),
...(data.autoSetup ? { autoSetup: data.autoSetup } : {}),
...(data.userInput !== undefined ? { userInput: data.userInput } : {}),
...(data.domainAccessAction ? { domainAccessAction: data.domainAccessAction } : {}),
...(data.action ? { action: data.action } : {}),

View File

@ -252,9 +252,12 @@ describe('InstanceAiCredentialSetup', () => {
// Auto-continue fires since all are selected
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'approved');
expect(confirmSpy).toHaveBeenCalledWith('req-1', true, undefined, {
type1: 'cred-123',
type2: 'cred-123',
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
kind: 'credentialSelection',
credentials: {
type1: 'cred-123',
type2: 'cred-123',
},
});
});
@ -275,7 +278,7 @@ describe('InstanceAiCredentialSetup', () => {
await userEvent.click(getByText('instanceAi.credential.deny'));
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
});
it('auto-continues when single credential is selected', async () => {
@ -293,7 +296,10 @@ describe('InstanceAiCredentialSetup', () => {
// Select credential — auto-continue should fire
await userEvent.click(getByTestId('credential-picker'));
expect(confirmSpy).toHaveBeenCalledWith('req-1', true, undefined, { type1: 'cred-123' });
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
kind: 'credentialSelection',
credentials: { type1: 'cred-123' },
});
expect(getByText('instanceAi.credential.allSelected')).toBeTruthy();
});

View File

@ -238,7 +238,7 @@ describe('InstanceAiWorkflowSetup', () => {
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
expect(getByText('instanceAi.workflowSetup.deferred')).toBeTruthy();
});
@ -276,13 +276,7 @@ describe('InstanceAiWorkflowSetup', () => {
expect(confirmSpy).toHaveBeenCalledWith(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
expect.objectContaining({ action: 'apply' }),
expect.objectContaining({ kind: 'setupWorkflowApply' }),
);
});
});
@ -360,13 +354,7 @@ describe('InstanceAiWorkflowSetup', () => {
expect(confirmSpy).toHaveBeenCalledWith(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
expect.objectContaining({ action: 'apply' }),
expect.objectContaining({ kind: 'setupWorkflowApply' }),
);
});
});
@ -405,13 +393,7 @@ describe('InstanceAiWorkflowSetup', () => {
expect(confirmSpy).toHaveBeenCalledWith(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
expect.objectContaining({ action: 'apply' }),
expect.objectContaining({ kind: 'setupWorkflowApply' }),
);
});
});
@ -440,7 +422,7 @@ describe('InstanceAiWorkflowSetup', () => {
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
expect(getByText('instanceAi.workflowSetup.deferred')).toBeTruthy();
});
});
@ -578,7 +560,7 @@ describe('InstanceAiWorkflowSetup', () => {
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
// Should defer since there's only 1 card and no selection
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
});
});
@ -853,14 +835,8 @@ describe('InstanceAiWorkflowSetup', () => {
expect(confirmSpy).toHaveBeenCalledWith(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
expect.objectContaining({
action: 'apply',
kind: 'setupWorkflowApply',
nodeParameters: { [nodeName]: { channel: '#general' } },
}),
);
@ -926,13 +902,7 @@ describe('InstanceAiWorkflowSetup', () => {
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalledWith(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
expect.objectContaining({ action: 'apply' }),
expect.objectContaining({ kind: 'setupWorkflowApply' }),
);
});
});

View File

@ -1040,53 +1040,27 @@ describe('useInstanceAiStore - gateway resource-decision confirmation', () => {
vi.clearAllMocks();
});
it('confirmAction passes resourceDecision to postConfirmation', async () => {
await store.confirmAction(
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowOnce',
);
it('confirmAction passes resourceDecision payload through to postConfirmation', async () => {
await store.confirmAction('req-1', {
kind: 'resourceDecision',
resourceDecision: 'allowOnce',
});
expect(mockPostConfirmation).toHaveBeenCalledOnce();
expect(mockPostConfirmation).toHaveBeenCalledWith(
expect.anything(),
'req-1',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowOnce',
);
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-1', {
kind: 'resourceDecision',
resourceDecision: 'allowOnce',
});
});
it('confirmResourceDecision calls postConfirmation with approved=true and the decision', async () => {
it('confirmResourceDecision calls postConfirmation with the decision token', async () => {
await store.confirmResourceDecision('req-2', 'allowForSession');
expect(mockPostConfirmation).toHaveBeenCalledOnce();
expect(mockPostConfirmation).toHaveBeenCalledWith(
expect.anything(),
'req-2',
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'allowForSession',
);
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-2', {
kind: 'resourceDecision',
resourceDecision: 'allowForSession',
});
});
it('confirmResourceDecision does not call postConfirmation when confirmAction throws', async () => {

View File

@ -138,7 +138,11 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb
telemetry.track('User finished providing input', eventProps);
store.resolveConfirmation(requestId, approved ? 'approved' : 'denied');
void store.confirmAction(requestId, approved, undefined, undefined, undefined, feedback);
void store.confirmAction(requestId, {
kind: 'approval',
approved,
...(feedback ? { userInput: feedback } : {}),
});
}
/** Find the latest plan-review confirmation from a planner child's submit-plan tool call.

View File

@ -49,17 +49,14 @@ const dropdownItems = computed<Array<ActionDropdownItem<DomainAction>>>(() =>
],
);
function handleAction(approved: boolean, domainAccessAction?: string) {
function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
resolved.value = true;
store.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied');
void store.confirmAction(
props.requestId,
approved,
undefined,
undefined,
undefined,
undefined,
domainAccessAction,
approved && domainAccessAction
? { kind: 'domainAccessApprove', domainAccessAction }
: { kind: 'domainAccessDeny' },
);
}
@ -67,8 +64,18 @@ function onPrimaryClick() {
handleAction(true, primaryAction.value);
}
const DOMAIN_ACTIONS: readonly DomainAction[] = [
'allow_once',
'allow_domain',
'allow_all',
] as const;
function isDomainAction(value: string): value is DomainAction {
return (DOMAIN_ACTIONS as readonly string[]).includes(value);
}
function onDropdownSelect(action: string) {
handleAction(true, action);
if (isDomainAction(action)) handleAction(true, action);
}
</script>

View File

@ -144,7 +144,7 @@ function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
[],
);
store.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
void store.confirmAction(conf.requestId, approved);
void store.confirmAction(conf.requestId, { kind: 'approval', approved });
}
function handleApproveAll(items: PendingConfirmationItem[]) {
@ -157,7 +157,7 @@ function handleApproveAll(items: PendingConfirmationItem[]) {
[],
);
store.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, true);
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
}
}
@ -178,7 +178,7 @@ function handleTextSubmit(conf: InstanceAiConfirmation) {
[],
);
store.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, true, undefined, undefined, undefined, value);
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value });
}
function handleTextSkip(conf: InstanceAiConfirmation) {
@ -188,7 +188,7 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
[{ label: conf.message, question: conf.message, input_type: 'text', options: [] }],
);
store.resolveConfirmation(conf.requestId, 'deferred');
void store.confirmAction(conf.requestId, false);
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false });
}
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
@ -230,17 +230,7 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
}
trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length });
store.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(
conf.requestId,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
answers,
);
void store.confirmAction(conf.requestId, { kind: 'questions', answers });
}
function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
@ -251,7 +241,7 @@ function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
{ num_tasks: numTasks },
);
store.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, true);
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
}
function handlePlanRequestChanges(
@ -266,7 +256,11 @@ function handlePlanRequestChanges(
{ num_tasks: numTasks, feedback },
);
store.resolveConfirmation(conf.requestId, 'denied');
void store.confirmAction(conf.requestId, false, undefined, undefined, undefined, feedback);
void store.confirmAction(conf.requestId, {
kind: 'approval',
approved: false,
userInput: feedback,
});
}
/** True when every item in the group is a generic approval (not domain/cred/text). */

View File

@ -279,7 +279,10 @@ async function handleContinue() {
isSubmitted.value = true;
const success = await store.confirmAction(props.requestId, true, undefined, credentials);
const success = await store.confirmAction(props.requestId, {
kind: 'credentialSelection',
credentials,
});
if (success) {
store.resolveConfirmation(props.requestId, 'approved');
} else {
@ -293,7 +296,10 @@ async function handleLater() {
isSubmitted.value = true;
isDeferred.value = true;
const success = await store.confirmAction(props.requestId, false);
const success = await store.confirmAction(props.requestId, {
kind: 'approval',
approved: false,
});
if (success) {
store.resolveConfirmation(props.requestId, 'deferred');
} else {

View File

@ -174,20 +174,11 @@ export function useSetupActions(deps: {
isApplying.value = true;
applyError.value = null;
const postSuccess = await deps.store.confirmAction(
deps.requestId.value,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
{
action: 'apply',
nodeCredentials,
nodeParameters,
},
);
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
kind: 'setupWorkflowApply',
nodeCredentials,
nodeParameters,
});
if (!postSuccess) {
isApplying.value = false;
@ -221,21 +212,12 @@ export function useSetupActions(deps: {
applyError.value = null;
const postSuccess = await deps.store.confirmAction(
deps.requestId.value,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
{
action: 'test-trigger',
testTriggerNode: nodeName,
nodeCredentials,
nodeParameters,
},
);
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
kind: 'setupWorkflowTestTrigger',
testTriggerNode: nodeName,
nodeCredentials,
nodeParameters,
});
if (!postSuccess) {
applyError.value = 'Failed to send trigger test request. Try again.';
@ -314,7 +296,10 @@ export function useSetupActions(deps: {
isSubmitted.value = true;
isDeferred.value = true;
const success = await deps.store.confirmAction(deps.requestId.value, false);
const success = await deps.store.confirmAction(deps.requestId.value, {
kind: 'approval',
approved: false,
});
if (success) {
deps.store.resolveConfirmation(deps.requestId.value, 'deferred');
} else {

View File

@ -4,7 +4,7 @@ import type {
InstanceAiAttachment,
InstanceAiEnsureThreadResponse,
InstanceAiSendMessageResponse,
InstanceAiConfirmResponse,
InstanceAiConfirmRequest,
} from '@n8n/api-types';
/**
@ -89,50 +89,14 @@ export async function postCancelTask(
/**
* POST /instance-ai/confirm/:requestId -> 200 OK
* Approve or deny a confirmation request (HITL).
* Resolve a confirmation request (HITL). The request body is a discriminated
* union on `kind`.
*/
export async function postConfirmation(
context: IRestApiContext,
requestId: string,
approved: boolean,
credentialId?: string,
credentials?: Record<string, string>,
autoSetup?: { credentialType: string },
userInput?: string,
domainAccessAction?: string,
setupWorkflowData?: {
action?: 'apply' | 'test-trigger';
nodeCredentials?: Record<string, Record<string, string>>;
nodeParameters?: Record<string, Record<string, unknown>>;
testTriggerNode?: string;
},
answers?: InstanceAiConfirmResponse['answers'],
resourceDecision?: string,
payload: InstanceAiConfirmRequest,
): Promise<void> {
const payload: InstanceAiConfirmResponse = {
approved,
...(credentialId ? { credentialId } : {}),
...(credentials ? { credentials } : {}),
...(autoSetup ? { autoSetup } : {}),
...(userInput !== undefined ? { userInput } : {}),
...(domainAccessAction
? {
domainAccessAction: domainAccessAction as InstanceAiConfirmResponse['domainAccessAction'],
}
: {}),
...(setupWorkflowData?.action ? { action: setupWorkflowData.action } : {}),
...(setupWorkflowData?.nodeCredentials
? { nodeCredentials: setupWorkflowData.nodeCredentials }
: {}),
...(setupWorkflowData?.nodeParameters
? { nodeParameters: setupWorkflowData.nodeParameters }
: {}),
...(setupWorkflowData?.testTriggerNode
? { testTriggerNode: setupWorkflowData.testTriggerNode }
: {}),
...(answers ? { answers } : {}),
...(resourceDecision ? { resourceDecision } : {}),
};
await makeRestApiRequest(context, 'POST', `/instance-ai/confirm/${requestId}`, payload);
}

View File

@ -10,7 +10,7 @@ import {
isSafeObjectKey,
UNLIMITED_CREDITS,
type InstanceAiConfirmation,
type InstanceAiConfirmResponse,
type InstanceAiConfirmRequest,
} from '@n8n/api-types';
import {
ensureThread,
@ -871,35 +871,10 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
async function confirmAction(
requestId: string,
approved: boolean,
credentialId?: string,
credentials?: Record<string, string>,
autoSetup?: { credentialType: string },
userInput?: string,
domainAccessAction?: string,
setupWorkflowData?: {
action?: 'apply' | 'test-trigger';
nodeCredentials?: Record<string, Record<string, string>>;
nodeParameters?: Record<string, Record<string, unknown>>;
testTriggerNode?: string;
},
answers?: InstanceAiConfirmResponse['answers'],
resourceDecision?: string,
payload: InstanceAiConfirmRequest,
): Promise<boolean> {
try {
await postConfirmation(
rootStore.restApiContext,
requestId,
approved,
credentialId,
credentials,
autoSetup,
userInput,
domainAccessAction,
setupWorkflowData,
answers,
resourceDecision,
);
await postConfirmation(rootStore.restApiContext, requestId, payload);
return true;
} catch {
toast.showError(new Error('Failed to send confirmation. Try again.'), 'Confirmation failed');
@ -909,18 +884,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
async function confirmResourceDecision(requestId: string, decision: string): Promise<void> {
resolveConfirmation(requestId, 'approved');
await confirmAction(
requestId,
true,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
decision,
);
await confirmAction(requestId, { kind: 'resourceDecision', resourceDecision: decision });
}
function toggleResearchMode(): void {

View File

@ -8,5 +8,6 @@ export type {
InstanceAiThreadSummary,
InstanceAiSSEConnectionState,
InstanceAiSendMessageResponse,
InstanceAiConfirmResponse,
InstanceAiConfirmRequest,
InstanceAiConfirmRequestKind,
} from '@n8n/api-types';