/** * Tests for Human-in-the-Loop (HITL) tool approval in ToolNode. * * Uses LangGraph's native interrupt()/Command({ resume }) pattern. * Tests verify: * - Policy evaluation (no config, scheduled, overrides, custom functions) * - Interrupt/resume flow with MemorySaver checkpointer * - Denial stops tool execution * - Modified args are applied on resume * - Multiple tool calls each get individual interrupts */ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages'; import { StateGraph, Annotation, messagesStateReducer, START, MemorySaver, Command, } from '@langchain/langgraph'; import type { BaseMessage } from '@langchain/core/messages'; import type { RunnableConfig } from '@langchain/core/runnables'; import type * as t from '@/types'; import { ToolNode, toolsCondition } from '@/tools/ToolNode'; import { GraphEvents } from '@/common'; // ============================================================================ // Test Fixtures // ============================================================================ /** Simple echo tool that returns its input */ const echoTool = tool( async ({ message }: { message: string }) => { return `Echo: ${message}`; }, { name: 'echo', description: 'Echoes the input message', schema: z.object({ message: z.string().describe('The message to echo'), }), } ); /** Simulated "send email" tool */ const sendEmailTool = tool( async ({ to, subject }: { to: string; subject: string }) => { return JSON.stringify({ status: 'sent', to, subject }); }, { name: 'send_email', description: 'Sends an email', schema: z.object({ to: z.string().describe('Recipient email'), subject: z.string().describe('Email subject'), }), } ); /** Simulated "search" tool - typically safe, no approval needed */ const searchTool = tool( async ({ query }: { query: string }) => { return JSON.stringify({ results: [`Result for: ${query}`] }); }, { name: 'search', description: 'Searches for information', schema: z.object({ query: z.string().describe('Search query'), }), } ); /** * Helper to build a graph with HITL support using interrupt/resume pattern. * Uses MemorySaver checkpointer for interrupt persistence. * Returns the compiled graph and a notification collector. */ function createTestGraph( tools: t.GenericTool[], toolApprovalConfig: t.ToolApprovalConfig, modelResponses: string[][] ): { // eslint-disable-next-line @typescript-eslint/no-explicit-any compiled: any; notifications: t.ToolApprovalNotification[]; config: Partial & { version: 'v1' | 'v2'; configurable: { thread_id: string }; }; } { const StateAnnotation = Annotation.Root({ messages: Annotation({ reducer: messagesStateReducer, default: () => [], }), }); let responseIndex = 0; /** Fake model node that returns pre-defined tool calls or text */ const callModel = async (_state: { messages: BaseMessage[]; }): Promise<{ messages: BaseMessage[] }> => { const responses = modelResponses[responseIndex] ?? ['Done.']; responseIndex++; // Check if the response is a tool call instruction (JSON format) if (responses.length === 1 && responses[0].startsWith('{')) { const parsed = JSON.parse(responses[0]); return { messages: [ new AIMessage({ content: '', tool_calls: [parsed], }), ], }; } return { messages: [new AIMessage({ content: responses.join(' ') })], }; }; const toolNode = new ToolNode({ tools, toolApprovalConfig, }); const routeMessage = (state: typeof StateAnnotation.State): string => { return toolsCondition(state, 'tools'); }; const workflow = new StateGraph(StateAnnotation) .addNode('agent', callModel) .addNode('tools', toolNode) .addEdge(START, 'agent') .addConditionalEdges('agent', routeMessage) .addEdge('tools', 'agent'); // MemorySaver is required for interrupt()/Command({ resume }) to work const checkpointer = new MemorySaver(); const compiled = workflow.compile({ checkpointer }); const notifications: t.ToolApprovalNotification[] = []; // Collect notification events (data-only, no resolve/reject) const config: Partial & { version: 'v1' | 'v2'; configurable: { thread_id: string }; } = { version: 'v2', configurable: { thread_id: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`, }, callbacks: [ { handleCustomEvent: async ( eventName: string, data: unknown ): Promise => { if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) { notifications.push(data as t.ToolApprovalNotification); } }, }, ], }; return { compiled, notifications, config }; } // ============================================================================ // Unit Tests: ToolApprovalConfig policy evaluation // ============================================================================ describe('HITL Tool Approval - Policy Evaluation', () => { test('no approval config means no interruption', async () => { // ToolNode without toolApprovalConfig should execute tools directly const toolNode = new ToolNode({ tools: [echoTool], // No toolApprovalConfig }); const input = { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'echo', args: { message: 'hello' }, id: 'call_1', type: 'tool_call' as const, }, ], }), ], }; // Should execute without interruption const result = await toolNode.invoke(input); expect(result.messages).toHaveLength(1); expect(result.messages[0]).toBeInstanceOf(ToolMessage); expect((result.messages[0] as ToolMessage).content).toContain( 'Echo: hello' ); }); test('scheduled execution context bypasses all approvals', async () => { // Even with defaultPolicy: 'always', scheduled should not interrupt const toolNode = new ToolNode({ tools: [echoTool], toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'scheduled', }, }); const input = { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'echo', args: { message: 'scheduled hello' }, id: 'call_2', type: 'tool_call' as const, }, ], }), ], }; // Should execute without interruption because context is 'scheduled' const result = await toolNode.invoke(input); expect(result.messages).toHaveLength(1); expect((result.messages[0] as ToolMessage).content).toContain( 'Echo: scheduled hello' ); }); test('tool with "never" override skips approval even when default is "always"', async () => { const toolNode = new ToolNode({ tools: [searchTool], toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', overrides: { search: 'never', }, }, }); const input = { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'search', args: { query: 'test query' }, id: 'call_3', type: 'tool_call' as const, }, ], }), ], }; // Should execute without interruption because override is 'never' const result = await toolNode.invoke(input); expect(result.messages).toHaveLength(1); expect((result.messages[0] as ToolMessage).content).toContain( 'Result for: test query' ); }); test('scheduled context bypasses custom function policy', async () => { const alwaysRequire = (): boolean => true; const toolNode = new ToolNode({ tools: [echoTool], toolApprovalConfig: { defaultPolicy: alwaysRequire, executionContext: 'scheduled', }, }); const input = { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'echo', args: { message: 'scheduled test' }, id: 'call_sched_2', type: 'tool_call' as const, }, ], }), ], }; const result = await toolNode.invoke(input); expect(result.messages).toHaveLength(1); expect((result.messages[0] as ToolMessage).content).toContain( 'Echo: scheduled test' ); }); test('scheduled context bypasses per-tool overrides', async () => { const toolNode = new ToolNode({ tools: [searchTool], toolApprovalConfig: { defaultPolicy: 'never', executionContext: 'scheduled', overrides: { search: 'always', }, }, }); const input = { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'search', args: { query: 'scheduled override test' }, id: 'call_sched_3', type: 'tool_call' as const, }, ], }), ], }; const result = await toolNode.invoke(input); expect(result.messages).toHaveLength(1); expect((result.messages[0] as ToolMessage).content).toContain( 'Result for: scheduled override test' ); }); }); // ============================================================================ // Integration Tests: Interrupt/Resume approval flow // ============================================================================ describe('HITL Tool Approval - Interrupt/Resume Flow', () => { jest.setTimeout(15000); test('interactive mode with "always" policy interrupts graph and resumes on approval', async () => { const { compiled, notifications, config } = createTestGraph( [echoTool as t.GenericTool], { defaultPolicy: 'always', executionContext: 'interactive', }, [ // First model response: request tool call [ JSON.stringify({ name: 'echo', args: { message: 'needs approval' }, id: 'call_int_1', type: 'tool_call', }), ], // Second model response (after tool executes): final text ['The echo returned the result.'], ] ); // First invoke: graph runs until interrupt() is hit const interruptResult = await compiled.invoke( { messages: [new HumanMessage('Say hello')] }, config ); // Verify the notification was dispatched expect(notifications).toHaveLength(1); expect(notifications[0].type).toBe('tool_approval_required'); expect(notifications[0].toolName).toBe('echo'); expect(notifications[0].toolArgs).toEqual({ message: 'needs approval' }); // Resume with approval — Command({ resume }) returns value to interrupt() const result = await compiled.invoke( new Command({ resume: { approved: true } as t.ToolApprovalResponse }), config ); // Verify the tool executed after approval const toolMessages = result.messages.filter( (m: BaseMessage) => m._getType() === 'tool' ); expect(toolMessages.length).toBeGreaterThan(0); expect(toolMessages[0].content.toString()).toContain( 'Echo: needs approval' ); // Verify final model response const lastMessage = result.messages[result.messages.length - 1]; expect(lastMessage.content).toContain('The echo returned the result'); }); test('denial stops tool execution and returns denial message', async () => { const { compiled, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'always', executionContext: 'interactive', }, [ // First model response: request send email [ JSON.stringify({ name: 'send_email', args: { to: 'user@example.com', subject: 'Test' }, id: 'call_deny_1', type: 'tool_call', }), ], // Second model response (after denial): acknowledge ['I understand, the email was not sent.'], ] ); // First invoke: hits interrupt await compiled.invoke( { messages: [new HumanMessage('Send an email')] }, config ); // Resume with denial const result = await compiled.invoke( new Command({ resume: { approved: false, feedback: 'Not now', } as t.ToolApprovalResponse, }), config ); // Find the tool message - it should be a denial const toolMessages = result.messages.filter( (m: BaseMessage) => m._getType() === 'tool' ); expect(toolMessages.length).toBeGreaterThan(0); const denialMsg = toolMessages.find((m: BaseMessage) => m.content.toString().includes('denied') ); expect(denialMsg).toBeDefined(); expect(denialMsg!.content.toString()).toContain('Not now'); expect(denialMsg!.content.toString()).toContain('denied'); expect(denialMsg!.content.toString()).toContain( 'proceed without executing' ); }); test('interactive context triggers interrupt (scheduled does not)', async () => { const { compiled, notifications, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'always', executionContext: 'interactive', }, [ [ JSON.stringify({ name: 'send_email', args: { to: 'user@example.com', subject: 'Interactive' }, id: 'call_int_1', type: 'tool_call', }), ], ['Done.'], ] ); // Graph should interrupt (not complete) await compiled.invoke( { messages: [new HumanMessage('send an email')] }, config ); // Notification WAS dispatched for interactive context expect(notifications.length).toBe(1); expect(notifications[0].toolName).toBe('send_email'); // Resume to complete the graph await compiled.invoke( new Command({ resume: { approved: true } as t.ToolApprovalResponse }), config ); }); test('custom function override evaluates args to determine approval', async () => { const { compiled, notifications, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'never', executionContext: 'interactive', overrides: { send_email: (_toolName: string, args: Record) => { const to = args.to as string; return !to.endsWith('@internal.com'); }, }, }, [ // Tool call to internal domain (should NOT require approval) [ JSON.stringify({ name: 'send_email', args: { to: 'colleague@internal.com', subject: 'Internal' }, id: 'call_func_1', type: 'tool_call', }), ], // Final text ['Email sent to internal address.'], ] ); const result = await compiled.invoke( { messages: [new HumanMessage('Send internal email')] }, config ); // No notifications should have been dispatched (internal domain bypasses approval) expect(notifications).toHaveLength(0); // Email should have been sent directly without interrupt const toolMessages = result.messages.filter( (m: BaseMessage) => m._getType() === 'tool' ); expect(toolMessages.length).toBeGreaterThan(0); expect(toolMessages[0].content.toString()).toContain( 'colleague@internal.com' ); }); test('custom function override triggers interrupt for external email', async () => { const { compiled, notifications, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'never', executionContext: 'interactive', overrides: { send_email: (_toolName: string, args: Record) => { const to = args.to as string; return !to.endsWith('@internal.com'); }, }, }, [ // Tool call to external domain (SHOULD require approval) [ JSON.stringify({ name: 'send_email', args: { to: 'external@gmail.com', subject: 'External' }, id: 'call_func_2', type: 'tool_call', }), ], // After approval ['Email sent to external address.'], ] ); // First invoke: hits interrupt for external domain await compiled.invoke( { messages: [new HumanMessage('Send external email')] }, config ); // Notification should have been dispatched (external domain) expect(notifications).toHaveLength(1); expect(notifications[0].toolName).toBe('send_email'); expect(notifications[0].toolArgs.to).toBe('external@gmail.com'); // Resume with approval const result = await compiled.invoke( new Command({ resume: { approved: true } as t.ToolApprovalResponse }), config ); // Email should have been sent after approval const lastMessage = result.messages[result.messages.length - 1]; expect(lastMessage.content).toContain('Email sent to external address'); }); test('modified args are used when human edits the tool call', async () => { const { compiled, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'always', executionContext: 'interactive', }, [ // Model requests send email with original args [ JSON.stringify({ name: 'send_email', args: { to: 'wrong@example.com', subject: 'Original Subject' }, id: 'call_edit_1', type: 'tool_call', }), ], // After tool execution with modified args ['Email sent with corrected details.'], ] ); // First invoke: hits interrupt await compiled.invoke( { messages: [new HumanMessage('Send email')] }, config ); // Resume with modified args const result = await compiled.invoke( new Command({ resume: { approved: true, modifiedArgs: { to: 'correct@example.com', subject: 'Corrected Subject', }, } as t.ToolApprovalResponse, }), config ); // Find the tool message to verify modified args were used const toolMessages = result.messages.filter( (m: BaseMessage) => m._getType() === 'tool' ); const sentMsg = toolMessages.find( (m: BaseMessage) => (m as ToolMessage).name === 'send_email' && m.content.toString().includes('correct@example.com') ); expect(sentMsg).toBeDefined(); expect(sentMsg!.content.toString()).toContain('Corrected Subject'); }); test('scheduled context auto-approves without dispatching events or interrupting', async () => { const { compiled, notifications, config } = createTestGraph( [sendEmailTool as t.GenericTool], { defaultPolicy: 'always', executionContext: 'scheduled', // Scheduled = auto-approve, no interrupt }, [ [ JSON.stringify({ name: 'send_email', args: { to: 'user@example.com', subject: 'Scheduled Email' }, id: 'call_sched_1', type: 'tool_call', }), ], ['Scheduled email sent.'], ] ); // Should complete without interrupting — no resume needed const result = await compiled.invoke( { messages: [new HumanMessage('Send scheduled email')] }, config ); // No notifications should be dispatched for scheduled execution expect(notifications).toHaveLength(0); // Email should have been sent directly const toolMessages = result.messages.filter( (m: BaseMessage) => m._getType() === 'tool' ); expect(toolMessages.length).toBeGreaterThan(0); expect(toolMessages[0].content.toString()).toContain('user@example.com'); }); test('multiple tool calls each get individual interrupts', async () => { const notifications: t.ToolApprovalNotification[] = []; const toolNode = new ToolNode({ tools: [echoTool, sendEmailTool] as t.GenericTool[], toolApprovalConfig: { defaultPolicy: 'always', executionContext: 'interactive', }, }); const StateAnnotation = Annotation.Root({ messages: Annotation({ reducer: messagesStateReducer, default: () => [], }), }); let callCount = 0; const callModel = async (_state: { messages: BaseMessage[]; }): Promise<{ messages: BaseMessage[] }> => { callCount++; if (callCount === 1) { return { messages: [ new AIMessage({ content: '', tool_calls: [ { name: 'echo', args: { message: 'test' }, id: 'c1', type: 'tool_call' as const, }, { name: 'send_email', args: { to: 'a@b.com', subject: 'Hi' }, id: 'c2', type: 'tool_call' as const, }, ], }), ], }; } return { messages: [new AIMessage({ content: 'Both done.' })] }; }; const routeMessage = (state: typeof StateAnnotation.State): string => toolsCondition(state, 'tools'); const workflow = new StateGraph(StateAnnotation) .addNode('agent', callModel) .addNode('tools', toolNode) .addEdge(START, 'agent') .addConditionalEdges('agent', routeMessage) .addEdge('tools', 'agent'); const checkpointer = new MemorySaver(); const compiled = workflow.compile({ checkpointer }); const config: Partial & { version: 'v1' | 'v2'; configurable: { thread_id: string }; } = { version: 'v2', configurable: { thread_id: `test-multi-${Date.now()}`, }, callbacks: [ { handleCustomEvent: async ( eventName: string, data: unknown ): Promise => { if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) { notifications.push(data as t.ToolApprovalNotification); } }, }, ], }; // First invoke: hits first interrupt await compiled.invoke({ messages: [new HumanMessage('Do both')] }, config); // First interrupt notification received expect(notifications.length).toBeGreaterThanOrEqual(1); // Resume first tool approval await compiled.invoke( new Command({ resume: { approved: true } as t.ToolApprovalResponse }), config ); // Second tool also requires approval — resume again await compiled.invoke( new Command({ resume: { approved: true } as t.ToolApprovalResponse }), config ); // Notifications fire on every re-execution (interrupt() replays the node), // so we check unique tool names rather than total count. const uniqueTools = [ ...new Set(notifications.map((e) => e.toolName)), ].sort(); expect(uniqueTools).toEqual(['echo', 'send_email']); }); });