/** * Unit tests for HITL (Human-in-the-Loop) approval bypass in ToolNode. * * Verifies that graph-managed tools (handoff/transfer tools) bypass HITL approval * via the `directToolNames` mechanism, while regular tools still respect the policy. */ import { ToolNode as CustomToolNode } from '@/tools/ToolNode'; import type { ToolApprovalConfig, GenericTool } from '@/types'; /** * Helper to create a minimal CustomToolNode with specific options. * Uses a no-op tool to satisfy the constructor's tool requirement. */ function createToolNode({ directToolNames, toolApprovalConfig, }: { directToolNames?: Set; toolApprovalConfig?: ToolApprovalConfig; }) { const noopTool = { name: 'noop', invoke: async () => 'ok', lc_namespace: ['test'], } as unknown as GenericTool; return new CustomToolNode({ tools: [noopTool], directToolNames, toolApprovalConfig, }); } /** * Access private `requiresApproval` method for testing. * This is acceptable in tests to verify internal HITL logic without * needing a full graph execution context. */ function callRequiresApproval( node: CustomToolNode, toolName: string, args: Record = {} ): boolean { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (node as any).requiresApproval(toolName, args); } describe('ToolNode HITL approval bypass', () => { describe('directToolNames bypass', () => { it('returns false when tool is in directToolNames', () => { const node = createToolNode({ directToolNames: new Set(['lc_transfer_to_agent_abc123']), toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc123')).toBe( false ); }); it('returns true for regular tools not in directToolNames', () => { const node = createToolNode({ directToolNames: new Set(['lc_transfer_to_agent_abc123']), toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'content_tool')).toBe(true); }); it('bypasses multiple handoff tools when all are in directToolNames', () => { const node = createToolNode({ directToolNames: new Set([ 'lc_transfer_to_agent_abc', 'lc_transfer_to_agent_xyz', ]), toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc')).toBe( false ); expect(callRequiresApproval(node, 'lc_transfer_to_agent_xyz')).toBe( false ); expect(callRequiresApproval(node, 'some_other_tool')).toBe(true); }); it('handles undefined directToolNames gracefully', () => { const node = createToolNode({ directToolNames: undefined, toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); // Without directToolNames, handoff tool hits the default policy expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc123')).toBe( true ); }); }); describe('executionContext bypass', () => { it('returns false for all tools when executionContext is scheduled', () => { const node = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'scheduled', }, }); expect(callRequiresApproval(node, 'content_tool')).toBe(false); expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc')).toBe( false ); }); it('returns false for all tools when executionContext is scheduled', () => { const node = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'scheduled', }, }); expect(callRequiresApproval(node, 'content_tool')).toBe(false); expect(callRequiresApproval(node, 'send_email')).toBe(false); }); }); describe('no toolApprovalConfig', () => { it('returns false when toolApprovalConfig is not provided', () => { const node = createToolNode({}); expect(callRequiresApproval(node, 'content_tool')).toBe(false); expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc')).toBe( false ); }); }); describe('policy evaluation', () => { it('returns true when defaultPolicy is always', () => { const node = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'content_tool')).toBe(true); }); it('returns false when defaultPolicy is never', () => { const node = createToolNode({ toolApprovalConfig: { defaultPolicy: 'never', executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'content_tool')).toBe(false); }); it('uses per-tool override over defaultPolicy', () => { const node = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', overrides: { safe_tool: 'never', }, executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'safe_tool')).toBe(false); expect(callRequiresApproval(node, 'dangerous_tool')).toBe(true); }); it('calls custom function policy with tool name and args', () => { const customPolicy = jest.fn().mockReturnValue(true); const node = createToolNode({ toolApprovalConfig: { defaultPolicy: customPolicy, executionContext: 'interactive', }, }); const args = { action: 'delete' }; callRequiresApproval(node, 'my_tool', args); expect(customPolicy).toHaveBeenCalledWith('my_tool', args); }); it('directToolNames bypass takes priority over custom function policy', () => { const customPolicy = jest.fn().mockReturnValue(true); const node = createToolNode({ directToolNames: new Set(['lc_transfer_to_agent_abc']), toolApprovalConfig: { defaultPolicy: customPolicy, executionContext: 'interactive', }, }); expect(callRequiresApproval(node, 'lc_transfer_to_agent_abc')).toBe( false ); // Custom function should NOT be called for directToolNames expect(customPolicy).not.toHaveBeenCalled(); }); }); describe('priority order', () => { it('checks in order: no config → executionContext → directToolNames → overrides → defaultPolicy', () => { // 1. No config → false const noConfig = createToolNode({}); expect(callRequiresApproval(noConfig, 'tool')).toBe(false); // 2. Scheduled context → false (regardless of policy) const scheduled = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'scheduled', }, }); expect(callRequiresApproval(scheduled, 'tool')).toBe(false); // 3. DirectToolNames → false (regardless of policy) const direct = createToolNode({ directToolNames: new Set(['tool']), toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(direct, 'tool')).toBe(false); // 4. Override → uses override (not default) const override = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', overrides: { tool: 'never' }, executionContext: 'interactive', }, }); expect(callRequiresApproval(override, 'tool')).toBe(false); // 5. Default policy → uses default const defaultOnly = createToolNode({ toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); expect(callRequiresApproval(defaultOnly, 'tool')).toBe(true); }); }); });