diff --git a/packages/@n8n/agents/src/runtime/__tests__/resume-approval-envelope.test.ts b/packages/@n8n/agents/src/runtime/__tests__/resume-approval-envelope.test.ts new file mode 100644 index 00000000000..f244fa87d6f --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/resume-approval-envelope.test.ts @@ -0,0 +1,79 @@ +/** + * Approval confirmations attach envelope-level fields (`userInput` free-text + * commentary, `scope`) to the resume payload alongside the tool's own resume + * fields. Tool resume schemas declare only their own fields, and a SUSPENDED + * resume validates against the schema serialized to JSON Schema — where + * `additionalProperties: false` rejects the envelope fields outright, leaving + * the run suspended forever. (A live resume validates against the zod object, + * which strips unknown keys, so the same payload passes there.) + * + * These tests pin the fallback that restores parity: on validation failure the + * runtime retries without the envelope fields. + */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { parseWithSchema } from '../../utils/parse'; +import { zodToJsonSchema } from '../../utils/zod'; +import { withoutApprovalEnvelopeFields } from '../loop/agent-runtime'; + +describe('withoutApprovalEnvelopeFields', () => { + it('strips userInput and scope, preserving the tool fields', () => { + expect( + withoutApprovalEnvelopeFields({ + approved: true, + userInput: 'Yes, publish it with the credential attached as is.', + scope: 'always', + }), + ).toEqual({ approved: true }); + }); + + it('strips one envelope field without touching other keys', () => { + expect( + withoutApprovalEnvelopeFields({ approved: true, action: 'apply', userInput: 'go ahead' }), + ).toEqual({ approved: true, action: 'apply' }); + }); + + it('returns undefined when no envelope field is present (nothing to retry)', () => { + expect(withoutApprovalEnvelopeFields({ approved: false })).toBeUndefined(); + }); + + it('returns undefined for non-object payloads', () => { + expect(withoutApprovalEnvelopeFields(undefined)).toBeUndefined(); + expect(withoutApprovalEnvelopeFields('approve')).toBeUndefined(); + expect(withoutApprovalEnvelopeFields([{ approved: true }])).toBeUndefined(); + }); + + it('does not mutate the original payload', () => { + const original = { approved: true, userInput: 'ok' }; + withoutApprovalEnvelopeFields(original); + expect(original).toEqual({ approved: true, userInput: 'ok' }); + }); +}); + +describe('suspended-resume validation parity', () => { + // The shape every tool resume schema shares, serialized exactly the way a + // suspended tool call persists it (zod object → JSON Schema with + // additionalProperties: false). + const approvalResumeJsonSchema = zodToJsonSchema(z.object({ approved: z.boolean() })); + if (!approvalResumeJsonSchema) throw new Error('failed to serialize the resume schema'); + + it('rejects an approval carrying commentary against the serialized schema', async () => { + const result = await parseWithSchema(approvalResumeJsonSchema, { + approved: true, + userInput: 'Yes, publish it.', + }); + expect(result.success).toBe(false); + }); + + it('accepts the same approval once the envelope fields are stripped', async () => { + const stripped = withoutApprovalEnvelopeFields({ + approved: true, + userInput: 'Yes, publish it.', + scope: 'always', + }); + expect(stripped).toBeDefined(); + const result = await parseWithSchema(approvalResumeJsonSchema, stripped); + expect(result).toEqual({ success: true, data: { approved: true } }); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index c3d318dd885..5b2a05dae44 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -153,6 +153,30 @@ interface LoopContext { pendingResume?: PendingResume; } +/** Envelope-level fields an approval confirmation may attach to a resume + * payload alongside the tool's own resume fields. */ +const APPROVAL_ENVELOPE_FIELDS = ['userInput', 'scope'] as const; + +/** + * A copy of the resume payload without the approval-envelope fields, or + * undefined when there is nothing to strip (not an object, or none present). + * Used as a fallback when a resume payload fails schema validation: tool + * resume schemas declare only their own fields, so envelope fields must not + * make an otherwise-valid approval unresumable. + */ +export function withoutApprovalEnvelopeFields(data: unknown): Record | undefined { + if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined; + const stripped: Record = { ...data }; + let removed = false; + for (const field of APPROVAL_ENVELOPE_FIELDS) { + if (field in stripped) { + delete stripped[field]; + removed = true; + } + } + return removed ? stripped : undefined; +} + /** * Core agent execution engine using the Vercel AI SDK directly. * @@ -372,7 +396,18 @@ export class AgentRuntime { const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema; if (!isCancellation(resumeData) && resumeSchema) { - const parseResult = await parseWithSchema(resumeSchema, data); + let parseResult = await parseWithSchema(resumeSchema, data); + if (!parseResult.success) { + // Approval confirmations carry envelope-level fields (`userInput` + // free-text commentary, `scope`) that tool resume schemas do not + // declare. A live resume validates against the zod schema, which + // strips unknown keys; a suspended resume validates against the + // serialized JSON schema (additionalProperties: false), which rejects + // them — leaving the run suspended forever. Retry without the + // envelope fields to restore parity. + const stripped = withoutApprovalEnvelopeFields(data); + if (stripped) parseResult = await parseWithSchema(resumeSchema, stripped); + } if (!parseResult.success) { throw new Error(`Invalid resume payload: ${parseResult.error}`); }