@illuma-ai/agents 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +87 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +14 -0
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +3 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/nodes/ApprovalGateNode.cjs +75 -0
  10. package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -0
  11. package/dist/cjs/run.cjs +45 -0
  12. package/dist/cjs/run.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +21 -18
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/types/graph.cjs.map +1 -1
  16. package/dist/cjs/utils/run.cjs +6 -1
  17. package/dist/cjs/utils/run.cjs.map +1 -1
  18. package/dist/esm/common/enum.mjs +2 -0
  19. package/dist/esm/common/enum.mjs.map +1 -1
  20. package/dist/esm/graphs/MultiAgentGraph.mjs +87 -1
  21. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  22. package/dist/esm/llm/bedrock/index.mjs +14 -0
  23. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  24. package/dist/esm/main.mjs +1 -0
  25. package/dist/esm/main.mjs.map +1 -1
  26. package/dist/esm/nodes/ApprovalGateNode.mjs +72 -0
  27. package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -0
  28. package/dist/esm/run.mjs +45 -0
  29. package/dist/esm/run.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +22 -19
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/esm/types/graph.mjs.map +1 -1
  33. package/dist/esm/utils/run.mjs +6 -1
  34. package/dist/esm/utils/run.mjs.map +1 -1
  35. package/dist/types/common/enum.d.ts +2 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/nodes/ApprovalGateNode.d.ts +49 -0
  38. package/dist/types/nodes/index.d.ts +2 -0
  39. package/dist/types/run.d.ts +25 -1
  40. package/dist/types/tools/ToolNode.d.ts +7 -5
  41. package/dist/types/types/graph.d.ts +31 -0
  42. package/dist/types/types/tools.d.ts +7 -9
  43. package/package.json +1 -1
  44. package/src/common/enum.ts +2 -0
  45. package/src/graphs/MultiAgentGraph.ts +108 -1
  46. package/src/index.ts +3 -0
  47. package/src/llm/bedrock/index.ts +17 -0
  48. package/src/nodes/ApprovalGateNode.ts +117 -0
  49. package/src/nodes/__tests__/ApprovalGateNode.test.ts +206 -0
  50. package/src/nodes/index.ts +5 -0
  51. package/src/run.ts +55 -1
  52. package/src/specs/agent-handoffs-bedrock.integration.test.ts +2 -2
  53. package/src/specs/agent-handoffs.test.ts +153 -6
  54. package/src/tools/ToolNode.ts +28 -23
  55. package/src/tools/__tests__/ToolApproval.test.ts +162 -325
  56. package/src/types/graph.ts +32 -0
  57. package/src/types/tools.ts +7 -9
  58. package/src/utils/run.ts +9 -1
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Tests for Human-in-the-Loop (HITL) tool approval in ToolNode.
3
3
  *
4
- * Tests the approval policy evaluation and the event-driven approval flow.
5
- * The HITL system dispatches ON_TOOL_APPROVAL_REQUIRED events that the host
6
- * (Ranger) handles by showing UI and resolving/rejecting the promise.
4
+ * Uses LangGraph's native interrupt()/Command({ resume }) pattern.
5
+ * Tests verify:
6
+ * - Policy evaluation (no config, scheduled, overrides, custom functions)
7
+ * - Interrupt/resume flow with MemorySaver checkpointer
8
+ * - Denial stops tool execution
9
+ * - Modified args are applied on resume
10
+ * - Multiple tool calls each get individual interrupts
7
11
  */
8
12
  import { tool } from '@langchain/core/tools';
9
13
  import { z } from 'zod';
@@ -13,6 +17,8 @@ import {
13
17
  Annotation,
14
18
  messagesStateReducer,
15
19
  START,
20
+ MemorySaver,
21
+ Command,
16
22
  } from '@langchain/langgraph';
17
23
  import type { BaseMessage } from '@langchain/core/messages';
18
24
  import type { RunnableConfig } from '@langchain/core/runnables';
@@ -68,22 +74,19 @@ const searchTool = tool(
68
74
  );
69
75
 
70
76
  /**
71
- * Helper to build a graph with HITL support for integration testing.
72
- * Returns the compiled graph and a way to intercept approval events.
77
+ * Helper to build a graph with HITL support using interrupt/resume pattern.
78
+ * Uses MemorySaver checkpointer for interrupt persistence.
79
+ * Returns the compiled graph and a notification collector.
73
80
  */
74
81
  function createTestGraph(
75
82
  tools: t.GenericTool[],
76
83
  toolApprovalConfig: t.ToolApprovalConfig,
77
- modelResponses: string[][],
78
- approvalHandler: (event: t.ToolApprovalEvent) => void
84
+ modelResponses: string[][]
79
85
  ): {
80
- compiled: {
81
- invoke: (
82
- input: { messages: BaseMessage[] },
83
- config?: Partial<RunnableConfig>
84
- ) => Promise<{ messages: BaseMessage[] }>;
85
- };
86
- config: Partial<RunnableConfig> & { version: 'v1' | 'v2' };
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ compiled: any;
88
+ notifications: t.ToolApprovalNotification[];
89
+ config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } };
87
90
  } {
88
91
  const StateAnnotation = Annotation.Root({
89
92
  messages: Annotation<BaseMessage[]>({
@@ -135,11 +138,18 @@ function createTestGraph(
135
138
  .addConditionalEdges('agent', routeMessage)
136
139
  .addEdge('tools', 'agent');
137
140
 
138
- const compiled = workflow.compile();
141
+ // MemorySaver is required for interrupt()/Command({ resume }) to work
142
+ const checkpointer = new MemorySaver();
143
+ const compiled = workflow.compile({ checkpointer });
144
+
145
+ const notifications: t.ToolApprovalNotification[] = [];
139
146
 
140
- // Create a config with custom event handler to intercept approval requests
141
- const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
147
+ // Collect notification events (data-only, no resolve/reject)
148
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
142
149
  version: 'v2',
150
+ configurable: {
151
+ thread_id: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
152
+ },
143
153
  callbacks: [
144
154
  {
145
155
  handleCustomEvent: async (
@@ -147,14 +157,14 @@ function createTestGraph(
147
157
  data: unknown
148
158
  ): Promise<void> => {
149
159
  if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
150
- approvalHandler(data as t.ToolApprovalEvent);
160
+ notifications.push(data as t.ToolApprovalNotification);
151
161
  }
152
162
  },
153
163
  },
154
164
  ],
155
165
  };
156
166
 
157
- return { compiled, config };
167
+ return { compiled, notifications, config };
158
168
  }
159
169
 
160
170
  // ============================================================================
@@ -265,7 +275,6 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
265
275
  });
266
276
 
267
277
  test('scheduled context bypasses custom function policy', async () => {
268
- // Even a custom function that always returns true should be skipped in scheduled context
269
278
  const alwaysRequire = (): boolean => true;
270
279
  const toolNode = new ToolNode({
271
280
  tools: [echoTool],
@@ -299,7 +308,6 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
299
308
  });
300
309
 
301
310
  test('scheduled context bypasses per-tool overrides', async () => {
302
- // Even with an explicit 'always' override for the tool, scheduled context should skip it
303
311
  const toolNode = new ToolNode({
304
312
  tools: [searchTool],
305
313
  toolApprovalConfig: {
@@ -333,58 +341,17 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
333
341
  'Result for: scheduled override test'
334
342
  );
335
343
  });
336
-
337
- test('interactive context still requires approval (scheduled does not)', async () => {
338
- // Verify that 'interactive' context DOES require approval (contrasts with scheduled)
339
- const approvalEvents: t.ToolApprovalEvent[] = [];
340
- const { compiled, config } = createTestGraph(
341
- [sendEmailTool],
342
- {
343
- defaultPolicy: 'always',
344
- executionContext: 'interactive',
345
- },
346
- [
347
- // First model call: request tool call
348
- [
349
- JSON.stringify({
350
- name: 'send_email',
351
- args: { to: 'user@example.com', subject: 'Interactive' },
352
- id: 'call_int_1',
353
- type: 'tool_call',
354
- }),
355
- ],
356
- // Second model call: finish after tool result
357
- ['Done.'],
358
- ],
359
- (event) => {
360
- approvalEvents.push(event);
361
- // Auto-approve for test to complete
362
- event.resolve({ approved: true });
363
- }
364
- );
365
-
366
- const _result = await compiled.invoke(
367
- { messages: [new HumanMessage('send an email')] },
368
- config
369
- );
370
-
371
- // Approval WAS required for interactive context
372
- expect(approvalEvents.length).toBe(1);
373
- expect(approvalEvents[0].toolName).toBe('send_email');
374
- });
375
344
  });
376
345
 
377
346
  // ============================================================================
378
- // Integration Tests: Event-driven approval flow
347
+ // Integration Tests: Interrupt/Resume approval flow
379
348
  // ============================================================================
380
349
 
381
- describe('HITL Tool Approval - Event-Driven Flow', () => {
350
+ describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
382
351
  jest.setTimeout(15000);
383
352
 
384
- test('interactive mode with "always" policy dispatches approval event and waits', async () => {
385
- const approvalEvents: t.ToolApprovalEvent[] = [];
386
-
387
- const { compiled, config } = createTestGraph(
353
+ test('interactive mode with "always" policy interrupts graph and resumes on approval', async () => {
354
+ const { compiled, notifications, config } = createTestGraph(
388
355
  [echoTool as t.GenericTool],
389
356
  {
390
357
  defaultPolicy: 'always',
@@ -402,24 +369,26 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
402
369
  ],
403
370
  // Second model response (after tool executes): final text
404
371
  ['The echo returned the result.'],
405
- ],
406
- (event) => {
407
- // Auto-approve after capturing the event
408
- approvalEvents.push(event);
409
- event.resolve({ approved: true });
410
- }
372
+ ]
411
373
  );
412
374
 
413
- const result = await compiled.invoke(
375
+ // First invoke: graph runs until interrupt() is hit
376
+ const interruptResult = await compiled.invoke(
414
377
  { messages: [new HumanMessage('Say hello')] },
415
378
  config
416
379
  );
417
380
 
418
- // Verify the approval event was dispatched
419
- expect(approvalEvents).toHaveLength(1);
420
- expect(approvalEvents[0].type).toBe('tool_approval_required');
421
- expect(approvalEvents[0].toolName).toBe('echo');
422
- expect(approvalEvents[0].toolArgs).toEqual({ message: 'needs approval' });
381
+ // Verify the notification was dispatched
382
+ expect(notifications).toHaveLength(1);
383
+ expect(notifications[0].type).toBe('tool_approval_required');
384
+ expect(notifications[0].toolName).toBe('echo');
385
+ expect(notifications[0].toolArgs).toEqual({ message: 'needs approval' });
386
+
387
+ // Resume with approval — Command({ resume }) returns value to interrupt()
388
+ const result = await compiled.invoke(
389
+ new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
390
+ config
391
+ );
423
392
 
424
393
  // Verify the tool executed after approval
425
394
  const toolMessages = result.messages.filter(
@@ -454,18 +423,21 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
454
423
  ],
455
424
  // Second model response (after denial): acknowledge
456
425
  ['I understand, the email was not sent.'],
457
- ],
458
- (event) => {
459
- // Deny the tool call
460
- event.resolve({ approved: false, feedback: 'Not now' });
461
- }
426
+ ]
462
427
  );
463
428
 
464
- const result = await compiled.invoke(
429
+ // First invoke: hits interrupt
430
+ await compiled.invoke(
465
431
  { messages: [new HumanMessage('Send an email')] },
466
432
  config
467
433
  );
468
434
 
435
+ // Resume with denial
436
+ const result = await compiled.invoke(
437
+ new Command({ resume: { approved: false, feedback: 'Not now' } as t.ToolApprovalResponse }),
438
+ config
439
+ );
440
+
469
441
  // Find the tool message - it should be a denial
470
442
  const toolMessages = result.messages.filter(
471
443
  (m: BaseMessage) => m._getType() === 'tool'
@@ -483,11 +455,45 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
483
455
  );
484
456
  });
485
457
 
486
- test('custom function override evaluates args to determine approval', async () => {
487
- // Only require approval for emails to external domains
488
- const approvalEvents: t.ToolApprovalEvent[] = [];
458
+ test('interactive context triggers interrupt (scheduled does not)', async () => {
459
+ const { compiled, notifications, config } = createTestGraph(
460
+ [sendEmailTool as t.GenericTool],
461
+ {
462
+ defaultPolicy: 'always',
463
+ executionContext: 'interactive',
464
+ },
465
+ [
466
+ [
467
+ JSON.stringify({
468
+ name: 'send_email',
469
+ args: { to: 'user@example.com', subject: 'Interactive' },
470
+ id: 'call_int_1',
471
+ type: 'tool_call',
472
+ }),
473
+ ],
474
+ ['Done.'],
475
+ ]
476
+ );
489
477
 
490
- const { compiled, config } = createTestGraph(
478
+ // Graph should interrupt (not complete)
479
+ await compiled.invoke(
480
+ { messages: [new HumanMessage('send an email')] },
481
+ config
482
+ );
483
+
484
+ // Notification WAS dispatched for interactive context
485
+ expect(notifications.length).toBe(1);
486
+ expect(notifications[0].toolName).toBe('send_email');
487
+
488
+ // Resume to complete the graph
489
+ await compiled.invoke(
490
+ new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
491
+ config
492
+ );
493
+ });
494
+
495
+ test('custom function override evaluates args to determine approval', async () => {
496
+ const { compiled, notifications, config } = createTestGraph(
491
497
  [sendEmailTool as t.GenericTool],
492
498
  {
493
499
  defaultPolicy: 'never',
@@ -511,11 +517,7 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
511
517
  ],
512
518
  // Final text
513
519
  ['Email sent to internal address.'],
514
- ],
515
- (event) => {
516
- approvalEvents.push(event);
517
- event.resolve({ approved: true });
518
- }
520
+ ]
519
521
  );
520
522
 
521
523
  const result = await compiled.invoke(
@@ -523,10 +525,10 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
523
525
  config
524
526
  );
525
527
 
526
- // No approval events should have been dispatched (internal domain)
527
- expect(approvalEvents).toHaveLength(0);
528
+ // No notifications should have been dispatched (internal domain bypasses approval)
529
+ expect(notifications).toHaveLength(0);
528
530
 
529
- // Email should have been sent directly
531
+ // Email should have been sent directly without interrupt
530
532
  const toolMessages = result.messages.filter(
531
533
  (m: BaseMessage) => m._getType() === 'tool'
532
534
  );
@@ -536,10 +538,8 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
536
538
  );
537
539
  });
538
540
 
539
- test('custom function override triggers approval for external email', async () => {
540
- const approvalEvents: t.ToolApprovalEvent[] = [];
541
-
542
- const { compiled, config } = createTestGraph(
541
+ test('custom function override triggers interrupt for external email', async () => {
542
+ const { compiled, notifications, config } = createTestGraph(
543
543
  [sendEmailTool as t.GenericTool],
544
544
  {
545
545
  defaultPolicy: 'never',
@@ -563,22 +563,25 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
563
563
  ],
564
564
  // After approval
565
565
  ['Email sent to external address.'],
566
- ],
567
- (event) => {
568
- approvalEvents.push(event);
569
- event.resolve({ approved: true });
570
- }
566
+ ]
571
567
  );
572
568
 
573
- const result = await compiled.invoke(
569
+ // First invoke: hits interrupt for external domain
570
+ await compiled.invoke(
574
571
  { messages: [new HumanMessage('Send external email')] },
575
572
  config
576
573
  );
577
574
 
578
- // Approval event should have been dispatched (external domain)
579
- expect(approvalEvents).toHaveLength(1);
580
- expect(approvalEvents[0].toolName).toBe('send_email');
581
- expect(approvalEvents[0].toolArgs.to).toBe('external@gmail.com');
575
+ // Notification should have been dispatched (external domain)
576
+ expect(notifications).toHaveLength(1);
577
+ expect(notifications[0].toolName).toBe('send_email');
578
+ expect(notifications[0].toolArgs.to).toBe('external@gmail.com');
579
+
580
+ // Resume with approval
581
+ const result = await compiled.invoke(
582
+ new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
583
+ config
584
+ );
582
585
 
583
586
  // Email should have been sent after approval
584
587
  const lastMessage = result.messages[result.messages.length - 1];
@@ -604,21 +607,26 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
604
607
  ],
605
608
  // After tool execution with modified args
606
609
  ['Email sent with corrected details.'],
607
- ],
608
- (event) => {
609
- // Approve with modified args
610
- event.resolve({
610
+ ]
611
+ );
612
+
613
+ // First invoke: hits interrupt
614
+ await compiled.invoke(
615
+ { messages: [new HumanMessage('Send email')] },
616
+ config
617
+ );
618
+
619
+ // Resume with modified args
620
+ const result = await compiled.invoke(
621
+ new Command({
622
+ resume: {
611
623
  approved: true,
612
624
  modifiedArgs: {
613
625
  to: 'correct@example.com',
614
626
  subject: 'Corrected Subject',
615
627
  },
616
- });
617
- }
618
- );
619
-
620
- const result = await compiled.invoke(
621
- { messages: [new HumanMessage('Send email')] },
628
+ } as t.ToolApprovalResponse,
629
+ }),
622
630
  config
623
631
  );
624
632
 
@@ -635,14 +643,12 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
635
643
  expect(sentMsg!.content.toString()).toContain('Corrected Subject');
636
644
  });
637
645
 
638
- test('scheduled context auto-approves without dispatching events', async () => {
639
- const approvalEvents: t.ToolApprovalEvent[] = [];
640
-
641
- const { compiled, config } = createTestGraph(
646
+ test('scheduled context auto-approves without dispatching events or interrupting', async () => {
647
+ const { compiled, notifications, config } = createTestGraph(
642
648
  [sendEmailTool as t.GenericTool],
643
649
  {
644
650
  defaultPolicy: 'always',
645
- executionContext: 'scheduled', // Scheduled = auto-approve
651
+ executionContext: 'scheduled', // Scheduled = auto-approve, no interrupt
646
652
  },
647
653
  [
648
654
  [
@@ -654,20 +660,17 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
654
660
  }),
655
661
  ],
656
662
  ['Scheduled email sent.'],
657
- ],
658
- (event) => {
659
- approvalEvents.push(event);
660
- event.resolve({ approved: true });
661
- }
663
+ ]
662
664
  );
663
665
 
666
+ // Should complete without interrupting — no resume needed
664
667
  const result = await compiled.invoke(
665
668
  { messages: [new HumanMessage('Send scheduled email')] },
666
669
  config
667
670
  );
668
671
 
669
- // No approval events should be dispatched for scheduled execution
670
- expect(approvalEvents).toHaveLength(0);
672
+ // No notifications should be dispatched for scheduled execution
673
+ expect(notifications).toHaveLength(0);
671
674
 
672
675
  // Email should have been sent directly
673
676
  const toolMessages = result.messages.filter(
@@ -677,94 +680,8 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
677
680
  expect(toolMessages[0].content.toString()).toContain('user@example.com');
678
681
  });
679
682
 
680
- test('approval promise rejection is handled gracefully (host error)', async () => {
681
- const toolNode = new ToolNode({
682
- tools: [echoTool] as t.GenericTool[],
683
- toolApprovalConfig: {
684
- defaultPolicy: 'always',
685
- executionContext: 'interactive',
686
- },
687
- });
688
-
689
- const StateAnnotation = Annotation.Root({
690
- messages: Annotation<BaseMessage[]>({
691
- reducer: messagesStateReducer,
692
- default: () => [],
693
- }),
694
- });
695
-
696
- let callCount = 0;
697
- const callModel = async (_state: {
698
- messages: BaseMessage[];
699
- }): Promise<{ messages: BaseMessage[] }> => {
700
- callCount++;
701
- if (callCount === 1) {
702
- return {
703
- messages: [
704
- new AIMessage({
705
- content: '',
706
- tool_calls: [
707
- {
708
- name: 'echo',
709
- args: { message: 'test' },
710
- id: 'call_reject_1',
711
- type: 'tool_call' as const,
712
- },
713
- ],
714
- }),
715
- ],
716
- };
717
- }
718
- return { messages: [new AIMessage({ content: 'Recovered.' })] };
719
- };
720
-
721
- const routeMessage = (state: typeof StateAnnotation.State): string =>
722
- toolsCondition(state, 'tools');
723
-
724
- const workflow = new StateGraph(StateAnnotation)
725
- .addNode('agent', callModel)
726
- .addNode('tools', toolNode)
727
- .addEdge(START, 'agent')
728
- .addConditionalEdges('agent', routeMessage)
729
- .addEdge('tools', 'agent');
730
-
731
- const compiled = workflow.compile();
732
- const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
733
- version: 'v2',
734
- callbacks: [
735
- {
736
- handleCustomEvent: async (
737
- eventName: string,
738
- data: unknown
739
- ): Promise<void> => {
740
- if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
741
- const event = data as t.ToolApprovalEvent;
742
- // Simulate host-side error (e.g., stream disconnected)
743
- event.reject(new Error('Stream closed before approval'));
744
- }
745
- },
746
- },
747
- ],
748
- };
749
-
750
- // The graph should handle rejection without crashing
751
- const result = await compiled.invoke(
752
- { messages: [new HumanMessage('Say hello')] },
753
- config
754
- );
755
-
756
- // Should have a tool message with error content
757
- const toolMessages = result.messages.filter(
758
- (m: BaseMessage) => m._getType() === 'tool'
759
- );
760
- expect(toolMessages.length).toBeGreaterThan(0);
761
- // Agent should continue after rejection
762
- const lastMessage = result.messages[result.messages.length - 1];
763
- expect(lastMessage._getType()).toBe('ai');
764
- });
765
-
766
- test('multiple tool calls each get individual approval', async () => {
767
- const approvalEvents: t.ToolApprovalEvent[] = [];
683
+ test('multiple tool calls each get individual interrupts', async () => {
684
+ const notifications: t.ToolApprovalNotification[] = [];
768
685
 
769
686
  const toolNode = new ToolNode({
770
687
  tools: [echoTool, sendEmailTool] as t.GenericTool[],
@@ -822,9 +739,14 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
822
739
  .addConditionalEdges('agent', routeMessage)
823
740
  .addEdge('tools', 'agent');
824
741
 
825
- const compiled = workflow.compile();
826
- const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
742
+ const checkpointer = new MemorySaver();
743
+ const compiled = workflow.compile({ checkpointer });
744
+
745
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
827
746
  version: 'v2',
747
+ configurable: {
748
+ thread_id: `test-multi-${Date.now()}`,
749
+ },
828
750
  callbacks: [
829
751
  {
830
752
  handleCustomEvent: async (
@@ -832,122 +754,37 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
832
754
  data: unknown
833
755
  ): Promise<void> => {
834
756
  if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
835
- const event = data as t.ToolApprovalEvent;
836
- approvalEvents.push(event);
837
- event.resolve({ approved: true });
757
+ notifications.push(data as t.ToolApprovalNotification);
838
758
  }
839
759
  },
840
760
  },
841
761
  ],
842
762
  };
843
763
 
844
- const result = await compiled.invoke(
764
+ // First invoke: hits first interrupt
765
+ await compiled.invoke(
845
766
  { messages: [new HumanMessage('Do both')] },
846
767
  config
847
768
  );
848
769
 
849
- // Both tools should have triggered approval events
850
- expect(approvalEvents).toHaveLength(2);
851
- expect(approvalEvents.map((e) => e.toolName).sort()).toEqual([
852
- 'echo',
853
- 'send_email',
854
- ]);
770
+ // First interrupt notification received
771
+ expect(notifications.length).toBeGreaterThanOrEqual(1);
855
772
 
856
- // Both tools should have executed
857
- const toolMessages = result.messages.filter(
858
- (m: BaseMessage) => m._getType() === 'tool'
773
+ // Resume first tool approval
774
+ await compiled.invoke(
775
+ new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
776
+ config
859
777
  );
860
- expect(toolMessages).toHaveLength(2);
861
- });
862
- });
863
778
 
864
- // ============================================================================
865
- // Type Tests: ToolApprovalConfig shape validation
866
- // ============================================================================
867
-
868
- describe('HITL Types', () => {
869
- test('ToolApprovalConfig accepts valid configurations', () => {
870
- const config1: t.ToolApprovalConfig = {
871
- defaultPolicy: 'always',
872
- executionContext: 'interactive',
873
- };
874
- expect(config1.defaultPolicy).toBe('always');
875
-
876
- const config2: t.ToolApprovalConfig = {
877
- defaultPolicy: 'never',
878
- executionContext: 'scheduled',
879
- };
880
- expect(config2.executionContext).toBe('scheduled');
881
-
882
- const config3: t.ToolApprovalConfig = {
883
- defaultPolicy: 'always',
884
- executionContext: 'interactive',
885
- overrides: {
886
- search: 'never',
887
- send_email: 'always',
888
- custom_tool: (_toolName, args) => (args.dangerous as boolean) === true,
889
- },
890
- };
891
- expect(config3.overrides).toBeDefined();
892
- expect(config3.overrides!.search).toBe('never');
893
- });
894
-
895
- test('ToolApprovalRequest has correct shape', () => {
896
- const request: t.ToolApprovalRequest = {
897
- type: 'tool_approval_required',
898
- toolCallId: 'call_123',
899
- toolName: 'send_email',
900
- toolArgs: { to: 'user@example.com' },
901
- agentId: 'agent-1',
902
- description: 'Send email to user',
903
- };
904
- expect(request.type).toBe('tool_approval_required');
905
- expect(request.toolName).toBe('send_email');
906
- });
907
-
908
- test('ToolApprovalResponse covers all response types', () => {
909
- const approved: t.ToolApprovalResponse = { approved: true };
910
- expect(approved.approved).toBe(true);
911
-
912
- const denied: t.ToolApprovalResponse = {
913
- approved: false,
914
- feedback: 'Too risky',
915
- };
916
- expect(denied.feedback).toBe('Too risky');
917
-
918
- const edited: t.ToolApprovalResponse = {
919
- approved: true,
920
- modifiedArgs: { to: 'corrected@example.com' },
921
- };
922
- expect(edited.modifiedArgs).toBeDefined();
923
- });
924
-
925
- test('ToolApprovalRule function receives toolName as first argument', () => {
926
- // Verify the function-based policy can use toolName for classification
927
- const actionPrefixes = ['send', 'create', 'delete', 'post'];
928
- const policy: t.ToolApprovalRule = (
929
- toolName: string,
930
- _args: Record<string, unknown>
931
- ) => {
932
- const lower = toolName.toLowerCase();
933
- return actionPrefixes.some((prefix) => lower.includes(prefix));
934
- };
935
-
936
- // Action tools should require approval
937
- expect(policy('send_email', { to: 'a@b.com' })).toBe(true);
938
- expect(policy('create_document', {})).toBe(true);
939
- expect(policy('delete_file', {})).toBe(true);
940
- expect(policy('post_message', {})).toBe(true);
941
-
942
- // Read-only tools should not
943
- expect(policy('search', { query: 'test' })).toBe(false);
944
- expect(policy('list_emails', {})).toBe(false);
945
- expect(policy('get_calendar', {})).toBe(false);
779
+ // Second tool also requires approval — resume again
780
+ await compiled.invoke(
781
+ new Command({ resume: { approved: true } as t.ToolApprovalResponse }),
782
+ config
783
+ );
946
784
 
947
- const config: t.ToolApprovalConfig = {
948
- defaultPolicy: policy,
949
- executionContext: 'interactive',
950
- };
951
- expect(typeof config.defaultPolicy).toBe('function');
785
+ // Notifications fire on every re-execution (interrupt() replays the node),
786
+ // so we check unique tool names rather than total count.
787
+ const uniqueTools = [...new Set(notifications.map((e) => e.toolName))].sort();
788
+ expect(uniqueTools).toEqual(['echo', 'send_email']);
952
789
  });
953
790
  });