n8n/packages/@n8n/nodes-langchain/utils/agent-execution/test/processHitlResponses.test.ts
yehorkardash a9f00ec49b
feat(AI Agent Node): Pass chat input in denial messages (#24748)
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-26 10:41:56 +00:00

225 lines
7.2 KiB
TypeScript

import { NodeConnectionTypes } from 'n8n-workflow';
import type { EngineResponse, ExecuteNodeResult, IDataObject, ITaskData } from 'n8n-workflow';
import { processHitlResponses } from '../processHitlResponses';
import type { HitlMetadata, RequestResponseMetadata } from '../types';
const createMockTaskData = (json: IDataObject): ITaskData => ({
executionTime: 0,
startTime: Date.now(),
executionIndex: 0,
source: [],
data: {
ai_tool: [[{ json }]],
},
});
const createHitlActionResponse = (
approved: boolean,
hitlMetadata: HitlMetadata,
actionId = 'action-1',
chatInput?: string,
): ExecuteNodeResult<RequestResponseMetadata> => ({
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'HITL Node',
input: {},
type: NodeConnectionTypes.AiTool,
id: actionId,
metadata: { hitl: hitlMetadata },
},
data: createMockTaskData({ approved, chatInput }),
});
const createNonHitlActionResponse = (
actionId = 'action-2',
): ExecuteNodeResult<RequestResponseMetadata> => ({
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'Regular Tool',
input: {},
type: NodeConnectionTypes.AiTool,
id: actionId,
metadata: {},
},
data: createMockTaskData({ result: 'success' }),
});
describe('processHitlResponses', () => {
const hitlMetadata = {
gatedToolNodeName: 'Gated Tool Node',
toolName: 'my_tool',
originalInput: { query: 'test' },
};
describe('empty/undefined responses', () => {
it('returns empty result for undefined response', () => {
const result = processHitlResponses(undefined, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toEqual([]);
});
it('returns empty result for response with no action responses', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
});
});
describe('non-HITL responses', () => {
it('passes through unchanged', () => {
const actionResponse = createNonHitlActionResponse();
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [actionResponse],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.processedResponse.actionResponses).toHaveLength(1);
expect(result.processedResponse.actionResponses[0]).toEqual(actionResponse);
});
});
describe('approved HITL responses', () => {
it('creates pending gated tool request', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest).toBeDefined();
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
const action = result.pendingGatedToolRequest!.actions[0];
expect(action.nodeName).toBe('Gated Tool Node');
expect(action.input).toEqual({ query: 'test', tool: 'my_tool' });
expect(action.id).toBe('action-1');
expect(action.metadata?.parentNodeName).toBe('HITL Node');
});
it('removes approved HITL response from processed responses', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.processedResponse.actionResponses).toHaveLength(0);
});
it('handles nested approval data format', () => {
const actionResponse: ExecuteNodeResult<RequestResponseMetadata> = {
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'HITL Node',
input: {},
type: NodeConnectionTypes.AiTool,
id: 'action-1',
metadata: { hitl: hitlMetadata },
},
data: createMockTaskData({ data: { approved: true } }),
};
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [actionResponse],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
});
});
describe('denied HITL responses', () => {
it('modifies response with denial message', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(false, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toHaveLength(1);
const processedData = result.processedResponse.actionResponses[0].data?.data
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
expect(processedData.output).toMatch(/reject/i);
});
it('modifies response with denial message and chat input', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(false, hitlMetadata, 'action-1', 'chat input')],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toHaveLength(1);
const processedData = result.processedResponse.actionResponses[0].data?.data
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
expect(processedData.output).toMatch(/chat input/i);
});
});
describe('mixed responses', () => {
it('processes HITL and non-HITL responses correctly', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
createNonHitlActionResponse('regular-1'),
createHitlActionResponse(true, hitlMetadata, 'hitl-approved'),
createHitlActionResponse(
false,
{ ...hitlMetadata, toolName: 'denied_tool' },
'hitl-denied',
),
createNonHitlActionResponse('regular-2'),
],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
// 2 non-HITL + 1 denied HITL = 3 (approved HITL is removed)
expect(result.processedResponse.actionResponses).toHaveLength(3);
});
});
describe('multiple approvals', () => {
it('batches multiple gated tool actions', () => {
const hitlMetadata2 = { ...hitlMetadata, gatedToolNodeName: 'Another Gated Tool' };
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
createHitlActionResponse(true, hitlMetadata, 'hitl-1'),
createHitlActionResponse(true, hitlMetadata2, 'hitl-2'),
],
metadata: { previousRequests: [{ action: {} as never, observation: 'prev' }] },
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest?.actions).toHaveLength(2);
expect(result.pendingGatedToolRequest?.metadata?.previousRequests).toHaveLength(1);
});
});
});